"""Persistent MPV pipeline helper. This process connects to MPV's IPC server, observes a user-data property for pipeline execution requests, runs the pipeline in-process, and posts results back to MPV via user-data properties. Why: - Avoid spawning a new Python process for every MPV action. - Enable MPV Lua scripts to trigger any cmdlet pipeline cheaply. Protocol (user-data properties): - Request: user-data/medeia-pipeline-request (JSON string) {"id": "...", "pipeline": "...", "seeds": [...]} (seeds optional) - Response: user-data/medeia-pipeline-response (JSON string) {"id": "...", "success": bool, "stdout": "...", "stderr": "...", "error": "..."} - Ready: user-data/medeia-pipeline-ready ("1") This helper is intentionally minimal: one request at a time, last-write-wins. """ from __future__ import annotations import argparse import json import os import sys import tempfile import time from pathlib import Path from typing import Any, Dict, Optional def _repo_root() -> Path: return Path(__file__).resolve().parent.parent # Make repo-local packages importable even when mpv starts us from another cwd. _ROOT = str(_repo_root()) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) from SYS.tasks import connect_ipc # noqa: E402 REQUEST_PROP = "user-data/medeia-pipeline-request" RESPONSE_PROP = "user-data/medeia-pipeline-response" READY_PROP = "user-data/medeia-pipeline-ready" OBS_ID_REQUEST = 1001 def _json_line(payload: Dict[str, Any]) -> bytes: return (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8") class MPVWire: def __init__(self, ipc_path: str, *, timeout: float = 5.0) -> None: self.ipc_path = ipc_path self.timeout = timeout self._fh: Optional[Any] = None self._req_id = 1 def connect(self) -> bool: self._fh = connect_ipc(self.ipc_path, timeout=self.timeout) return self._fh is not None @property def fh(self): if self._fh is None: raise RuntimeError("Not connected") return self._fh def send(self, command: list[Any]) -> int: self._req_id = (self._req_id + 1) % 1000000 req_id = self._req_id self.fh.write(_json_line({"command": command, "request_id": req_id})) self.fh.flush() return req_id def set_property(self, name: str, value: Any) -> int: return self.send(["set_property", name, value]) def observe_property(self, obs_id: int, name: str, fmt: str = "string") -> int: # mpv requires an explicit format argument. return self.send(["observe_property", obs_id, name, fmt]) def read_message(self) -> Optional[Dict[str, Any]]: raw = self.fh.readline() if raw == b"": return {"event": "__eof__"} if not raw: return None try: return json.loads(raw.decode("utf-8", errors="replace")) except Exception: return None def _run_pipeline(pipeline_text: str, *, seeds: Any = None) -> Dict[str, Any]: # Import after sys.path fix. from TUI.pipeline_runner import PipelineExecutor # noqa: WPS433 executor = PipelineExecutor() result = executor.run_pipeline(pipeline_text, seeds=seeds) return { "success": bool(result.success), "stdout": result.stdout or "", "stderr": result.stderr or "", "error": result.error, } def _parse_request(data: Any) -> Optional[Dict[str, Any]]: if data is None: return None if isinstance(data, str): text = data.strip() if not text: return None try: obj = json.loads(text) except Exception: return None return obj if isinstance(obj, dict) else None if isinstance(data, dict): return data return None def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(prog="mpv-pipeline-helper") parser.add_argument("--ipc", required=True, help="mpv --input-ipc-server path") parser.add_argument("--timeout", type=float, default=5.0) args = parser.parse_args(argv) # Ensure all in-process cmdlets that talk to MPV pick up the exact IPC server # path used by this helper (which comes from the running MPV instance). os.environ["MEDEIA_MPV_IPC"] = str(args.ipc) error_log_dir = Path(tempfile.gettempdir()) last_error_log = error_log_dir / "medeia-mpv-pipeline-last-error.log" def _write_error_log(text: str, *, req_id: str) -> Optional[str]: try: error_log_dir.mkdir(parents=True, exist_ok=True) except Exception: pass payload = (text or "").strip() if not payload: return None stamped = error_log_dir / f"medeia-mpv-pipeline-error-{req_id}.log" try: stamped.write_text(payload, encoding="utf-8", errors="replace") except Exception: stamped = None try: last_error_log.write_text(payload, encoding="utf-8", errors="replace") except Exception: pass return str(stamped) if stamped else str(last_error_log) wire = MPVWire(args.ipc, timeout=float(args.timeout)) if not wire.connect(): return 2 # Mark ready ASAP. try: wire.set_property(READY_PROP, "1") except Exception: pass # Observe request property changes. try: wire.observe_property(OBS_ID_REQUEST, REQUEST_PROP, "string") except Exception: return 3 last_seen_id: Optional[str] = None while True: msg = wire.read_message() if msg is None: time.sleep(0.05) continue if msg.get("event") == "__eof__": return 0 if msg.get("event") != "property-change": continue if msg.get("id") != OBS_ID_REQUEST: continue req = _parse_request(msg.get("data")) if not req: continue req_id = str(req.get("id") or "") pipeline_text = str(req.get("pipeline") or "").strip() seeds = req.get("seeds") if not req_id or not pipeline_text: continue if last_seen_id == req_id: continue last_seen_id = req_id try: run = _run_pipeline(pipeline_text, seeds=seeds) resp = { "id": req_id, "success": bool(run.get("success")), "stdout": run.get("stdout", ""), "stderr": run.get("stderr", ""), "error": run.get("error"), } except Exception as exc: resp = { "id": req_id, "success": False, "stdout": "", "stderr": "", "error": f"{type(exc).__name__}: {exc}", } if not resp.get("success"): details = "" if resp.get("error"): details += str(resp.get("error")) if resp.get("stderr"): details = (details + "\n" if details else "") + str(resp.get("stderr")) log_path = _write_error_log(details, req_id=req_id) if log_path: resp["log_path"] = log_path try: wire.set_property(RESPONSE_PROP, json.dumps(resp, ensure_ascii=False)) except Exception: # If posting results fails, there's nothing more useful to do. pass if __name__ == "__main__": raise SystemExit(main())