252 lines
7.4 KiB
Python
252 lines
7.4 KiB
Python
"""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())
|