engine.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  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. # Property names vary across mpv releases ("position" vs "playback-time",
  61. # "length" vs "duration"), and newer builds (>= ~0.41) have no state property
  62. # at all. Candidates are tried in order once per process start; the first one
  63. # that answers with a usable value wins.
  64. _PROP_CANDIDATES: dict[str, tuple[str, ...]] = {
  65. "state": ("playback-state", "state"),
  66. "pos": ("position", "playback-time", "time-pos"),
  67. "dur": ("length", "duration"),
  68. }
  69. def _as_float(x) -> float:
  70. if x is None or isinstance(x, bool):
  71. return -1.0
  72. try:
  73. return float(x)
  74. except (TypeError, ValueError):
  75. return -1.0
  76. def _as_bool(x) -> bool:
  77. if isinstance(x, bool):
  78. return x
  79. try:
  80. return bool(float(x))
  81. except (TypeError, ValueError):
  82. return False
  83. class MpEngine:
  84. """An mpv process controlled through its JSON IPC unix socket."""
  85. def __init__(self, binary: str | None = None, no_config: bool = False,
  86. sock_path: str | None = None):
  87. self.bin = find_mpv(binary)
  88. self.no_config = no_config
  89. self.sock_path = sock_path or os.path.join(
  90. tempfile.gettempdir(), f".asot-ipc-{os.getpid()}.sock"
  91. )
  92. self.mpv_log = os.path.join(
  93. tempfile.gettempdir(), f".asot-ipc-{os.getpid()}.log"
  94. )
  95. self._errlog: "io.TextIOWrapper | None" = None
  96. self.proc: subprocess.Popen | None = None
  97. self._sock: socket.socket | None = None
  98. self._cid = 0
  99. self.url = ""
  100. self.last_event: dict = {}
  101. self._prop_names: dict[str, str] = {}
  102. self._adv: tuple[float, float] | None = None
  103. # ----------------------------------------------------------------------
  104. def alive(self) -> bool:
  105. return self.proc is not None and self.proc.poll() is None
  106. def play(self, url: str, seek_to: float = 0.0, muted: bool = False,
  107. volume: float = 0.8):
  108. """(Re)start playback of ``url``; resume at ``seek_to`` seconds if set."""
  109. self.stop()
  110. try:
  111. os.unlink(self.sock_path)
  112. except OSError:
  113. pass
  114. args = [
  115. self.bin,
  116. "--no-video",
  117. "--terminal=no", # fully disable mpv terminal use (including keys)
  118. "--really-quiet",
  119. f"--input-ipc-server={self.sock_path}",
  120. "--loop=0",
  121. url,
  122. ]
  123. if self.no_config:
  124. args.append("--no-config")
  125. self.url = url
  126. # Keep mpv's own stderr for diagnostics; referenced from the error
  127. # messages when the stream cannot be started.
  128. self._errlog = open(self.mpv_log, "w")
  129. self.proc = subprocess.Popen(
  130. args,
  131. stdout=subprocess.DEVNULL,
  132. stderr=self._errlog,
  133. stdin=subprocess.DEVNULL,
  134. )
  135. self._connect()
  136. self._errlog.flush()
  137. # Fresh mpv process → re-discover which property names this build
  138. # speaks (newer mpv dropped "playback-state" and friends).
  139. self._prop_names = {}
  140. self._adv = None
  141. for logical, cands in _PROP_CANDIDATES.items():
  142. self._prop_names[logical] = self._discover_property(logical, cands)
  143. try:
  144. self.set_mute(muted)
  145. self.set_volume(volume)
  146. self._wait_ready()
  147. except EngineError:
  148. self.stop()
  149. raise
  150. if seek_to > 1.0:
  151. try:
  152. self.seek(seek_to)
  153. except EngineError:
  154. pass
  155. def _connect(self):
  156. """Open the IPC socket (mpv creates it at startup)."""
  157. self._sock = None
  158. deadline = time.time() + 15.0
  159. while time.time() < deadline:
  160. if self.proc is not None and self.proc.poll() is not None:
  161. rc = self.proc.returncode
  162. raise EngineError(
  163. f"mpv exited before playback could start "
  164. f"(return code {rc}) — the stream may be unavailable "
  165. f"(see {self.mpv_log})."
  166. )
  167. try:
  168. self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
  169. self._sock.connect(self.sock_path)
  170. return
  171. except OSError:
  172. pass
  173. time.sleep(0.2)
  174. self.stop()
  175. raise EngineError("timed out waiting for mpv to create its IPC socket")
  176. def _wait_ready(self):
  177. """Block until playback begins (or fails). While waiting, re-probe
  178. property names for this mpv build: newer builds only report some of
  179. them once the media metadata has loaded, so the one-shot probe at
  180. connect time can miss."""
  181. deadline = time.time() + 18.0
  182. while time.time() < deadline:
  183. # Re-discover any property we still haven't found; candidates are
  184. # retried until one answers or the deadline passes.
  185. for logical, cands in _PROP_CANDIDATES.items():
  186. if self._prop_names.get(logical) is None:
  187. self._prop_names[logical] = self._discover_property(
  188. logical, cands)
  189. state_name = self._prop_names.get("state")
  190. state = self._get(state_name) if state_name else None
  191. if isinstance(state, str) and state in ("playing", "buffering",
  192. "paused", "eof"):
  193. return
  194. dur_name = self._prop_names.get("dur")
  195. if dur_name:
  196. d = _as_float(self._get(dur_name))
  197. if d > 60.0:
  198. return
  199. if self.proc is not None and self.proc.poll() is not None:
  200. rc = self.proc.returncode
  201. raise EngineError(
  202. f"mpv failed to load '{self.url}' (return code {rc}) — "
  203. f"check your network or the stream URL "
  204. f"(see {self.mpv_log})."
  205. )
  206. time.sleep(0.35)
  207. raise EngineError("timed out waiting for mpv to become ready")
  208. # ----------------------------------------------------------------------
  209. def status(self) -> Status:
  210. """Snapshot of playback state."""
  211. state_name = self._prop_names.get("state")
  212. state = self._get(state_name) if state_name else None
  213. paused = _as_bool(self._get("pause"))
  214. muted = _as_bool(self._get("mute"))
  215. volume = _as_float(self._get("volume"))
  216. # mpv reports "volume" as a 0-100 percentage; normalize to 0-1.
  217. if volume > 0.0:
  218. volume /= 100.0
  219. pos_name = self._prop_names.get("pos")
  220. dur_name = self._prop_names.get("dur")
  221. pos = _as_float(self._get(pos_name)) if pos_name else -1.0
  222. length = _as_float(self._get(dur_name)) if dur_name else -1.0
  223. state_s = state if (isinstance(state, str) and state) \
  224. else self._derived_state(paused, pos, length)
  225. return Status(
  226. position=pos,
  227. length=length,
  228. state=state_s,
  229. paused=paused,
  230. muted=muted,
  231. volume=volume,
  232. )
  233. def seek(self, seconds: float):
  234. """Seek to ``seconds`` from episode start (absolute)."""
  235. self._cmd(["seek", round(max(0.0, min(seconds, 9_999_999.0)), 3), "absolute"])
  236. def set_pause(self, paused: bool):
  237. self._cmd(["set_property", "pause", paused])
  238. def set_mute(self, muted: bool):
  239. self._cmd(["set_property", "mute", muted])
  240. def set_volume(self, volume: float):
  241. """Set volume; mpv's ``volume`` property is a 0-100 percentage."""
  242. pct = max(0.0, min(1.0, float(volume))) * 100.0
  243. self._cmd(["set_property", "volume", round(pct, 2)])
  244. # ----------------------------------------------------------------------
  245. # low-level helpers
  246. # ----------------------------------------------------------------------
  247. def _derived_state(self, paused: bool, pos: float, length: float) -> str:
  248. """Infer playback state for mpv builds without a state property
  249. (e.g. newer mpv >= 0.41). Position normally advances ~1:1 with real
  250. time while playing; a position that stays flat for a long stretch means
  251. buffering or end of stream."""
  252. now = time.time()
  253. if paused:
  254. return "paused"
  255. if pos < 0.0:
  256. return "buffering" if length > 0.0 else "playing"
  257. if length > 10.0 and pos >= length - 2.0:
  258. return "eof"
  259. if self._adv is None:
  260. self._adv = (pos, now)
  261. return "playing"
  262. last_pos, last_t = self._adv
  263. advanced = pos - last_pos
  264. if advanced > 0.5: # real progress → update reference
  265. self._adv = (pos, now)
  266. return "playing"
  267. stall = now - last_t # seconds without real progress
  268. if stall > 8.0:
  269. return "eof" # no progress for a while → end of stream
  270. if stall > 2.0:
  271. return "buffering"
  272. return "playing"
  273. def _get(self, name: str):
  274. """Get a property by name; None when this mpv build lacks it."""
  275. try:
  276. return self._cmd(["get_property", name])
  277. except EngineError as e:
  278. if "not found" in str(e) or "unavailable" in str(e):
  279. return None
  280. raise
  281. def _discover_property(self, logical: str,
  282. candidates: tuple[str, ...]) -> str | None:
  283. """Try ``candidates`` in order; return the first property this mpv
  284. build reports a usable value for (None if none work)."""
  285. for cand in candidates:
  286. d = self._get(cand)
  287. if logical == "state":
  288. if isinstance(d, str) and d:
  289. return cand
  290. else:
  291. if _as_float(d) != -1.0:
  292. return cand
  293. return None
  294. def _cmd(self, cmd, timeout: float = 4.0):
  295. """Send one JSON command; return its ``data`` (raises on failure)."""
  296. if not self.alive():
  297. raise EngineError("mpv process is dead")
  298. self._cid += 1
  299. cmd_id = self._cid
  300. self._sock.sendall(
  301. (json.dumps({"command": cmd, "command_id": cmd_id}) + "\n").encode())
  302. deadline = time.time() + timeout
  303. while True:
  304. remaining = max(0.01, deadline - time.time())
  305. rlist, _, _ = select.select([self._sock], [], [], remaining)
  306. if rlist:
  307. try:
  308. data = self._sock.recv(65536)
  309. for lin in data.split(b"\n"):
  310. if not lin.strip():
  311. continue
  312. try:
  313. obj = json.loads(lin.decode())
  314. except json.JSONDecodeError:
  315. continue
  316. if obj.get("event") is not None:
  317. # Unsolicited event — remember it; keep waiting.
  318. self.last_event = obj
  319. else:
  320. # Ack. Older mpv acks carry command/command_id/
  321. # type; newer builds (>= ~0.41) use
  322. # request_id/data/error instead. Both shapes
  323. # carry the result under "data" and no "event".
  324. error = obj.get("error")
  325. if error and error != "success":
  326. raise EngineError(
  327. f"mpv rejected {cmd!r}: {error}")
  328. return obj.get("data")
  329. except (ConnectionResetError, OSError):
  330. raise EngineError("mpv IPC socket closed unexpectedly")
  331. if time.time() >= deadline:
  332. if self.proc and self.proc.poll() is None:
  333. self.proc.kill()
  334. self.proc.wait()
  335. raise EngineError(f"no reply from mpv ({timeout:.1f}s timeout)")
  336. def stop(self):
  337. try:
  338. self._cmd(["stop"], timeout=1.5)
  339. except Exception:
  340. pass
  341. try:
  342. if self.proc:
  343. self.proc.terminate()
  344. self.proc.wait(timeout=3.0)
  345. except (AttributeError, subprocess.SubprocessError):
  346. if self.proc is not None:
  347. self.proc.kill()
  348. self.proc.wait()
  349. try:
  350. if self._sock:
  351. self._sock.close()
  352. except OSError:
  353. pass
  354. try:
  355. os.unlink(self.sock_path)
  356. except OSError:
  357. pass
  358. try:
  359. if self._errlog is not None:
  360. self._errlog.close()
  361. except OSError:
  362. pass
  363. self._errlog = None
  364. self.proc = None
  365. self._sock = None
  366. # ---------------------------------------------------------------------------
  367. # Fake engine: lets the full player (UI + state + key loop) run without mpv
  368. # or a live stream. Used for tests and `asot --fake`.
  369. # ---------------------------------------------------------------------------
  370. class FakeEngine:
  371. def __init__(self, url: str, length: float = 75 * 60.0, speed: float = 1.0):
  372. self.url = url
  373. self._length = float(length)
  374. self._speed = speed
  375. self._pos = 0.0
  376. self.paused = False
  377. self.muted = False
  378. self.volume = 0.8
  379. self.state = "stop"
  380. self.played_url = None
  381. self.seek_count = 0
  382. def play(self, url, seek_to=0.0, muted=False, volume=0.8):
  383. self.url = url
  384. self.played_url = url
  385. self._pos = max(0.0, min(seek_to, self._length))
  386. self.paused = False
  387. self.muted = bool(muted)
  388. self.volume = max(0.0, min(1.0, float(volume)))
  389. self.state = "buffering" if self._length < 120 else "playing"
  390. def status(self) -> Status:
  391. if not self.paused and self._length > 0:
  392. self._pos += self._speed
  393. if self._pos >= self._length:
  394. self.state = "eof"
  395. self._pos = self._length
  396. elif self.played_url == self.url and self._pos <= 1.5:
  397. self.state = "buffering"
  398. else:
  399. self.state = "playing"
  400. return Status(
  401. position=self._pos,
  402. length=self._length,
  403. state=self.state,
  404. paused=self.paused,
  405. muted=self.muted,
  406. volume=self.volume,
  407. )
  408. def seek(self, pos_seconds):
  409. self.seek_count += 1
  410. self._pos = max(0.0, min(pos_seconds, self._length))
  411. def set_pause(self, paused):
  412. self.paused = paused
  413. def set_mute(self, muted):
  414. self.muted = muted
  415. def set_volume(self, volume):
  416. self.volume = max(0.0, min(1.0, float(volume)))
  417. def stop(self):
  418. self.state = "stop"
  419. self.played_url = None
  420. def alive(self) -> bool:
  421. return True