Files
Medios-Macina/MPV/pipeline_helper.py
2026-01-05 07:51:19 -08:00

1112 lines
38 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
MEDEIA_MPV_HELPER_VERSION = "2025-12-19"
import argparse
import json
import os
import sys
import tempfile
import time
import logging
import re
import hashlib
import subprocess
import platform
from pathlib import Path
from typing import Any, Dict, Optional
def _repo_root() -> Path:
return Path(__file__).resolve().parent.parent
def _runtime_config_root() -> Path:
"""Best-effort config root for runtime execution.
MPV can spawn this helper from an installed location while setting `cwd` to
the repo root (see MPV.mpv_ipc). Prefer `cwd` when it contains `config.conf`.
"""
try:
cwd = Path.cwd().resolve()
if (cwd / "config.conf").exists():
return cwd
except Exception:
pass
return _repo_root()
# 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 MPV.mpv_ipc import MPVIPCClient # noqa: E402
from SYS.config import load_config # noqa: E402
from SYS.logger import set_debug, debug, set_thread_stream # 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 _run_pipeline(pipeline_text: str, *, seeds: Any = None) -> Dict[str, Any]:
# Import after sys.path fix.
from TUI.pipeline_runner import PipelineExecutor # noqa: WPS433
def _table_to_payload(table: Any) -> Optional[Dict[str, Any]]:
if table is None:
return None
try:
title = getattr(table, "title", "")
except Exception:
title = ""
rows_payload = []
try:
rows = getattr(table, "rows", None)
except Exception:
rows = None
if isinstance(rows, list):
for r in rows:
cols_payload = []
try:
cols = getattr(r, "columns", None)
except Exception:
cols = None
if isinstance(cols, list):
for c in cols:
try:
cols_payload.append(
{
"name": getattr(c,
"name",
""),
"value": getattr(c,
"value",
""),
}
)
except Exception:
continue
sel_args = None
try:
sel = getattr(r, "selection_args", None)
if isinstance(sel, list):
sel_args = [str(x) for x in sel]
except Exception:
sel_args = None
rows_payload.append(
{
"columns": cols_payload,
"selection_args": sel_args
}
)
# Only return JSON-serializable data (Lua only needs title + rows).
return {
"title": str(title or ""),
"rows": rows_payload
}
executor = PipelineExecutor()
result = executor.run_pipeline(pipeline_text, seeds=seeds)
table_payload = None
try:
table_payload = _table_to_payload(getattr(result, "result_table", None))
except Exception:
table_payload = None
return {
"success": bool(result.success),
"stdout": result.stdout or "",
"stderr": result.stderr or "",
"error": result.error,
"table": table_payload,
}
def _run_op(op: str, data: Any) -> Dict[str, Any]:
"""Run a helper-only operation.
These are NOT cmdlets and are not available via CLI pipelines. They exist
solely so MPV Lua can query lightweight metadata (e.g., autocomplete lists)
without inventing user-facing commands.
"""
op_name = str(op or "").strip().lower()
if op_name in {"run-detached",
"run_detached",
"pipeline-detached",
"pipeline_detached"}:
pipeline_text = ""
seeds = None
if isinstance(data, dict):
pipeline_text = str(data.get("pipeline") or "").strip()
seeds = data.get("seeds")
if not pipeline_text:
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "Missing pipeline",
"table": None,
}
py = sys.executable or "python"
if platform.system() == "Windows":
try:
exe = str(py or "").strip()
except Exception:
exe = ""
low = exe.lower()
if low.endswith("python.exe"):
try:
candidate = exe[:-10] + "pythonw.exe"
if os.path.exists(candidate):
py = candidate
except Exception:
pass
cmd = [
py,
str((_repo_root() / "CLI.py").resolve()),
"pipeline",
"--pipeline",
pipeline_text,
]
if seeds is not None:
try:
cmd.extend(["--seeds-json", json.dumps(seeds, ensure_ascii=False)])
except Exception:
# Best-effort; seeds are optional.
pass
popen_kwargs: Dict[str,
Any] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"cwd": str(_repo_root()),
}
if platform.system() == "Windows":
flags = 0
try:
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008))
except Exception:
flags |= 0x00000008
try:
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
except Exception:
flags |= 0x08000000
popen_kwargs["creationflags"] = int(flags)
try:
si = subprocess.STARTUPINFO()
si.dwFlags |= int(
getattr(subprocess,
"STARTF_USESHOWWINDOW",
0x00000001)
)
si.wShowWindow = subprocess.SW_HIDE
popen_kwargs["startupinfo"] = si
except Exception:
pass
else:
popen_kwargs["start_new_session"] = True
try:
proc = subprocess.Popen(cmd, **popen_kwargs)
except Exception as exc:
return {
"success": False,
"stdout": "",
"stderr": "",
"error":
f"Failed to spawn detached pipeline: {type(exc).__name__}: {exc}",
"table": None,
}
return {
"success": True,
"stdout": "",
"stderr": "",
"error": None,
"table": None,
"pid": int(getattr(proc,
"pid",
0) or 0),
}
# Provide store backend choices using the same source as CLI/Typer autocomplete.
if op_name in {"store-choices",
"store_choices",
"get-store-choices",
"get_store_choices"}:
# IMPORTANT:
# - Prefer runtime cwd for config discovery (mpv spawns us with cwd=repo_root).
# - Avoid returning a cached empty result if config was loaded before it existed.
try:
from SYS.config import reload_config # noqa: WPS433
from Store import Store # noqa: WPS433
config_root = _runtime_config_root()
cfg = reload_config(config_dir=config_root)
storage = Store(config=cfg, suppress_debug=True)
backends = storage.list_backends() or []
choices = sorted({str(n)
for n in backends if str(n).strip()})
# Fallback: if initialization gated all backends (e.g., missing deps or offline stores),
# still return configured instance names so the UI can present something.
if not choices:
store_cfg = cfg.get("store") if isinstance(cfg, dict) else None
if isinstance(store_cfg, dict):
seen = set()
for _, instances in store_cfg.items():
if not isinstance(instances, dict):
continue
for instance_key, instance_cfg in instances.items():
name = None
if isinstance(instance_cfg, dict):
name = instance_cfg.get("NAME"
) or instance_cfg.get("name")
candidate = str(name or instance_key or "").strip()
if candidate:
seen.add(candidate)
choices = sorted(seen)
debug(f"[store-choices] config_dir={config_root} choices={len(choices)}")
return {
"success": True,
"stdout": "",
"stderr": "",
"error": None,
"table": None,
"choices": choices,
}
except Exception as exc:
return {
"success": False,
"stdout": "",
"stderr": "",
"error": f"store-choices failed: {type(exc).__name__}: {exc}",
"table": None,
"choices": [],
}
# Provide yt-dlp format list for a URL (for MPV "Change format" menu).
# Returns a ResultTable-like payload so the Lua UI can render without running cmdlets.
if op_name in {"ytdlp-formats",
"ytdlp_formats",
"ytdl-formats",
"ytdl_formats"}:
try:
url = None
if isinstance(data, dict):
url = data.get("url")
url = str(url or "").strip()
if not url:
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "Missing url",
"table": None,
}
# Fast gate: only for streaming URLs yt-dlp knows about.
try:
from tool.ytdlp import is_url_supported_by_ytdlp # noqa: WPS433
if not is_url_supported_by_ytdlp(url):
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "URL not supported by yt-dlp",
"table": None,
}
except Exception:
# If probing support fails, still attempt extraction and let yt-dlp decide.
pass
try:
import yt_dlp # type: ignore
except Exception as exc:
return {
"success": False,
"stdout": "",
"stderr": "",
"error":
f"yt-dlp module not available: {type(exc).__name__}: {exc}",
"table": None,
}
cookiefile = None
try:
from tool.ytdlp import YtDlpTool # noqa: WPS433
cfg = load_config() or {}
cookie_path = YtDlpTool(cfg).resolve_cookiefile()
if cookie_path is not None:
cookiefile = str(cookie_path)
except Exception:
cookiefile = None
ydl_opts: Dict[str,
Any] = {
"quiet": True,
"no_warnings": True,
"socket_timeout": 20,
"retries": 2,
"skip_download": True,
# Avoid accidentally expanding huge playlists on load.
"noplaylist": True,
"noprogress": True,
}
if cookiefile:
ydl_opts["cookiefile"] = cookiefile
def _format_bytes(n: Any) -> str:
try:
v = float(n)
except Exception:
return ""
if v <= 0:
return ""
units = ["B", "KB", "MB", "GB", "TB"]
i = 0
while v >= 1024 and i < len(units) - 1:
v /= 1024.0
i += 1
if i == 0:
return f"{int(v)} {units[i]}"
return f"{v:.1f} {units[i]}"
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(url, download=False)
# Debug: dump a short summary of the format list to the helper log.
try:
formats_any = info.get("formats") if isinstance(info, dict) else None
count = len(formats_any) if isinstance(formats_any, list) else 0
_append_helper_log(
f"[ytdlp-formats] extracted formats count={count} url={url}"
)
if isinstance(formats_any, list) and formats_any:
limit = 60
for i, f in enumerate(formats_any[:limit], start=1):
if not isinstance(f, dict):
continue
fid = str(f.get("format_id") or "")
ext = str(f.get("ext") or "")
note = f.get("format_note") or f.get("format") or ""
vcodec = str(f.get("vcodec") or "")
acodec = str(f.get("acodec") or "")
size = f.get("filesize") or f.get("filesize_approx")
res = str(f.get("resolution") or "")
if not res:
try:
w = f.get("width")
h = f.get("height")
if w and h:
res = f"{int(w)}x{int(h)}"
elif h:
res = f"{int(h)}p"
except Exception:
res = ""
_append_helper_log(
f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}"
)
if count > limit:
_append_helper_log(
f"[ytdlp-formats] (truncated; total={count})"
)
except Exception:
pass
# Optional: dump the full extracted JSON for inspection.
try:
dump = os.environ.get("MEDEIA_MPV_YTDLP_DUMP", "").strip()
if dump and dump != "0" and isinstance(info, dict):
h = hashlib.sha1(url.encode("utf-8",
errors="replace")).hexdigest()[:10]
out_path = _repo_root() / "Log" / f"ytdlp-probe-{h}.json"
out_path.write_text(
json.dumps(info,
ensure_ascii=False,
indent=2),
encoding="utf-8",
errors="replace",
)
_append_helper_log(f"[ytdlp-formats] wrote probe json: {out_path}")
except Exception:
pass
if not isinstance(info, dict):
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "yt-dlp returned non-dict info",
"table": None,
}
formats = info.get("formats")
if not isinstance(formats, list) or not formats:
return {
"success": True,
"stdout": "",
"stderr": "",
"error": None,
"table": {
"title": "Formats",
"rows": []
},
}
rows = []
for fmt in formats:
if not isinstance(fmt, dict):
continue
format_id = str(fmt.get("format_id") or "").strip()
if not format_id:
continue
# Prefer human-ish resolution.
resolution = str(fmt.get("resolution") or "").strip()
if not resolution:
w = fmt.get("width")
h = fmt.get("height")
try:
if w and h:
resolution = f"{int(w)}x{int(h)}"
elif h:
resolution = f"{int(h)}p"
except Exception:
resolution = ""
ext = str(fmt.get("ext") or "").strip()
size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx"))
# Build selection args compatible with MPV Lua picker.
selection_args = ["-format", format_id]
rows.append(
{
"columns": [
{
"name": "ID",
"value": format_id
},
{
"name": "Resolution",
"value": resolution or ""
},
{
"name": "Ext",
"value": ext or ""
},
{
"name": "Size",
"value": size or ""
},
],
"selection_args":
selection_args,
}
)
return {
"success": True,
"stdout": "",
"stderr": "",
"error": None,
"table": {
"title": "Formats",
"rows": rows
},
}
except Exception as exc:
return {
"success": False,
"stdout": "",
"stderr": "",
"error": f"{type(exc).__name__}: {exc}",
"table": None,
}
return {
"success": False,
"stdout": "",
"stderr": "",
"error": f"Unknown op: {op_name}",
"table": None,
}
def _helper_log_path() -> Path:
try:
d = _repo_root() / "Log"
d.mkdir(parents=True, exist_ok=True)
return d / "medeia-mpv-helper.log"
except Exception:
return Path(tempfile.gettempdir()) / "medeia-mpv-helper.log"
def _append_helper_log(text: str) -> None:
payload = (text or "").rstrip()
if not payload:
return
try:
path = _helper_log_path()
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "a", encoding="utf-8", errors="replace") as fh:
fh.write(payload + "\n")
except Exception:
return
def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]:
"""Best-effort singleton lock per IPC path.
Multiple helpers subscribing to mpv log-message events causes duplicated
log output. Use a tiny file lock to ensure one helper per mpv instance.
"""
try:
safe = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(ipc_path or ""))
if not safe:
safe = "mpv"
# Keep lock files out of the repo's Log/ directory to avoid clutter.
lock_dir = Path(tempfile.gettempdir()) / "medeia-mpv-helper"
lock_dir.mkdir(parents=True, exist_ok=True)
lock_path = lock_dir / f"medeia-mpv-helper-{safe}.lock"
fh = open(lock_path, "a+", encoding="utf-8", errors="replace")
if os.name == "nt":
try:
import msvcrt # type: ignore
fh.seek(0)
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
except Exception:
try:
fh.close()
except Exception:
pass
return None
else:
try:
import fcntl # type: ignore
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except Exception:
try:
fh.close()
except Exception:
pass
return None
return fh
except Exception:
return None
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=15.0)
args = parser.parse_args(argv)
# Load config once and configure logging similar to CLI.pipeline.
try:
cfg = load_config() or {}
except Exception:
cfg = {}
try:
debug_enabled = bool(isinstance(cfg, dict) and cfg.get("debug", False))
set_debug(debug_enabled)
if debug_enabled:
logging.basicConfig(
level=logging.DEBUG,
format="[%(name)s] %(levelname)s: %(message)s",
stream=sys.stderr,
)
for noisy in ("httpx",
"httpcore",
"httpcore.http11",
"httpcore.connection"):
try:
logging.getLogger(noisy).setLevel(logging.WARNING)
except Exception:
pass
except Exception:
pass
# 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)
# Ensure single helper instance per ipc.
_lock_fh = _acquire_ipc_lock(str(args.ipc))
if _lock_fh is None:
_append_helper_log(
f"[helper] another instance already holds lock for ipc={args.ipc}; exiting"
)
return 0
try:
_append_helper_log(
f"[helper] version={MEDEIA_MPV_HELPER_VERSION} started ipc={args.ipc}"
)
try:
_append_helper_log(
f"[helper] file={Path(__file__).resolve()} cwd={Path.cwd().resolve()}"
)
except Exception:
pass
try:
runtime_root = _runtime_config_root()
_append_helper_log(
f"[helper] config_root={runtime_root} exists={bool((runtime_root / 'config.conf').exists())}"
)
except Exception:
pass
debug(f"[mpv-helper] logging to: {_helper_log_path()}")
except Exception:
pass
# Route SYS.logger output into the helper log file so diagnostics are not
# lost in mpv's console/terminal output.
try:
class _HelperLogStream:
def __init__(self) -> None:
self._pending = ""
def write(self, s: str) -> int:
if not s:
return 0
text = self._pending + str(s)
lines = text.splitlines(keepends=True)
if lines and not lines[-1].endswith(("\n", "\r")):
self._pending = lines[-1]
lines = lines[:-1]
else:
self._pending = ""
for line in lines:
payload = line.rstrip("\r\n")
if payload:
_append_helper_log("[py] " + payload)
return len(s)
def flush(self) -> None:
if self._pending:
_append_helper_log("[py] " + self._pending.rstrip("\r\n"))
self._pending = ""
set_thread_stream(_HelperLogStream())
except Exception:
pass
# Prefer a stable repo-local log folder for discoverability.
error_log_dir = _repo_root() / "Log"
try:
error_log_dir.mkdir(parents=True, exist_ok=True)
except Exception:
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)
# Connect to mpv's JSON IPC. On Windows, the pipe can exist but reject opens
# briefly during startup; also mpv may create the IPC server slightly after
# the Lua script launches us. Retry until timeout.
connect_deadline = time.time() + max(0.5, float(args.timeout))
last_connect_error: Optional[str] = None
client = MPVIPCClient(socket_path=args.ipc, timeout=0.5, silent=True)
while True:
try:
if client.connect():
break
except Exception as exc:
last_connect_error = f"{type(exc).__name__}: {exc}"
if time.time() > connect_deadline:
_append_helper_log(
f"[helper] failed to connect ipc={args.ipc} error={last_connect_error or 'timeout'}"
)
return 2
# Keep trying.
time.sleep(0.10)
# Mark ready ASAP and keep it fresh.
# Use a unix timestamp so the Lua side can treat it as a heartbeat.
last_ready_ts: float = 0.0
def _touch_ready() -> None:
nonlocal last_ready_ts
now = time.time()
# Throttle updates to reduce IPC chatter.
if (now - last_ready_ts) < 0.75:
return
try:
client.send_command_no_wait(
["set_property_string",
READY_PROP,
str(int(now))]
)
last_ready_ts = now
except Exception:
return
# Mirror mpv's own log messages into our helper log file so debugging does
# not depend on the mpv on-screen console or mpv's log-file.
try:
# IMPORTANT: mpv debug logs can be extremely chatty (especially ytdl_hook)
# and can starve request handling. Default to warn unless explicitly overridden.
level = os.environ.get("MEDEIA_MPV_HELPER_MPVLOG", "").strip() or "warn"
client.send_command_no_wait(["request_log_messages", level])
_append_helper_log(f"[helper] requested mpv log messages level={level}")
except Exception:
pass
# De-dup/throttle mpv log-message lines (mpv and yt-dlp can be very chatty).
last_mpv_line: Optional[str] = None
last_mpv_count: int = 0
last_mpv_ts: float = 0.0
def _flush_mpv_repeat() -> None:
nonlocal last_mpv_line, last_mpv_count
if last_mpv_line and last_mpv_count > 1:
_append_helper_log(f"[mpv] (previous line repeated {last_mpv_count}x)")
last_mpv_line = None
last_mpv_count = 0
# Observe request property changes.
try:
client.send_command_no_wait(
["observe_property",
OBS_ID_REQUEST,
REQUEST_PROP,
"string"]
)
except Exception:
return 3
# Mark ready only after the observer is installed to avoid races where Lua
# sends a request before we can receive property-change notifications.
try:
_touch_ready()
_append_helper_log(f"[helper] ready heartbeat armed prop={READY_PROP}")
except Exception:
pass
# Pre-compute store choices at startup and publish to a cached property so Lua
# can read immediately without waiting for a request/response cycle (which may timeout).
try:
startup_choices_payload = _run_op("store-choices", None)
startup_choices = (
startup_choices_payload.get("choices")
if isinstance(startup_choices_payload,
dict) else None
)
if isinstance(startup_choices, list):
preview = ", ".join(str(x) for x in startup_choices[:50])
_append_helper_log(
f"[helper] startup store-choices count={len(startup_choices)} items={preview}"
)
# Publish to a cached property for Lua to read without IPC request.
try:
cached_json = json.dumps(
{
"success": True,
"choices": startup_choices
},
ensure_ascii=False
)
client.send_command_no_wait(
[
"set_property_string",
"user-data/medeia-store-choices-cached",
cached_json
]
)
_append_helper_log(
f"[helper] published store-choices to user-data/medeia-store-choices-cached"
)
except Exception as exc:
_append_helper_log(
f"[helper] failed to publish store-choices: {type(exc).__name__}: {exc}"
)
else:
_append_helper_log("[helper] startup store-choices unavailable")
except Exception as exc:
_append_helper_log(
f"[helper] startup store-choices failed: {type(exc).__name__}: {exc}"
)
# Also publish config temp directory if available
try:
from SYS.config import load_config
cfg = load_config()
temp_dir = cfg.get("temp", "").strip() or os.getenv("TEMP") or "/tmp"
if temp_dir:
client.send_command_no_wait(
["set_property_string",
"user-data/medeia-config-temp",
temp_dir]
)
_append_helper_log(
f"[helper] published config temp to user-data/medeia-config-temp={temp_dir}"
)
except Exception as exc:
_append_helper_log(
f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}"
)
last_seen_id: Optional[str] = None
try:
_append_helper_log(f"[helper] connected to ipc={args.ipc}")
except Exception:
pass
while True:
msg = client.read_message(timeout=0.25)
if msg is None:
# Keep READY fresh even when idle (Lua may clear it on timeouts).
_touch_ready()
time.sleep(0.02)
continue
if msg.get("event") == "__eof__":
try:
_flush_mpv_repeat()
except Exception:
pass
return 0
if msg.get("event") == "log-message":
try:
level = str(msg.get("level") or "")
prefix = str(msg.get("prefix") or "")
text = str(msg.get("text") or "").rstrip()
if not text:
continue
# Filter excessive noise unless debug is enabled.
if not debug_enabled:
lower_prefix = prefix.lower()
if "quic" in lower_prefix and "DEBUG:" in text:
continue
# Suppress progress-bar style lines (keep true errors).
if ("ETA" in text or "%" in text) and ("ERROR:" not in text
and "WARNING:" not in text):
# Typical yt-dlp progress bar line.
if text.lstrip().startswith("["):
continue
line = f"[mpv {level}] {prefix} {text}".strip()
now = time.time()
if last_mpv_line == line and (now - last_mpv_ts) < 2.0:
last_mpv_count += 1
last_mpv_ts = now
continue
_flush_mpv_repeat()
last_mpv_line = line
last_mpv_count = 1
last_mpv_ts = now
_append_helper_log(line)
except Exception:
pass
continue
if msg.get("event") != "property-change":
continue
if msg.get("id") != OBS_ID_REQUEST:
continue
raw = msg.get("data")
req = _parse_request(raw)
if not req:
try:
if isinstance(raw, str) and raw.strip():
snippet = raw.strip().replace("\r", "").replace("\n", " ")
if len(snippet) > 220:
snippet = snippet[:220] + ""
_append_helper_log(
f"[request-raw] could not parse request json: {snippet}"
)
except Exception:
pass
continue
req_id = str(req.get("id") or "")
op = str(req.get("op") or "").strip()
data = req.get("data")
pipeline_text = str(req.get("pipeline") or "").strip()
seeds = req.get("seeds")
if not req_id:
continue
if last_seen_id == req_id:
continue
last_seen_id = req_id
try:
label = pipeline_text if pipeline_text else (
op and ("op=" + op) or "(empty)"
)
_append_helper_log(f"\n[request {req_id}] {label}")
except Exception:
pass
try:
if op:
run = _run_op(op, data)
else:
if not pipeline_text:
continue
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"),
"table": run.get("table"),
}
if "choices" in run:
resp["choices"] = run.get("choices")
except Exception as exc:
resp = {
"id": req_id,
"success": False,
"stdout": "",
"stderr": "",
"error": f"{type(exc).__name__}: {exc}",
"table": None,
}
# Persist helper output for debugging MPV menu interactions.
try:
if resp.get("stdout"):
_append_helper_log("[stdout]\n" + str(resp.get("stdout")))
if resp.get("stderr"):
_append_helper_log("[stderr]\n" + str(resp.get("stderr")))
if resp.get("error"):
_append_helper_log("[error]\n" + str(resp.get("error")))
except Exception:
pass
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:
# IMPORTANT: don't wait for a response here; waiting would consume
# async events and can drop/skip property-change notifications.
client.send_command_no_wait(
[
"set_property_string",
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())