dfslkjelf
This commit is contained in:
1084
MPV/LUA/main.lua
1084
MPV/LUA/main.lua
File diff suppressed because it is too large
Load Diff
255
MPV/mpv_ipc.py
255
MPV/mpv_ipc.py
@@ -198,11 +198,13 @@ class MPV:
|
||||
ipc_path: Optional[str] = None,
|
||||
lua_script_path: Optional[str | Path] = None,
|
||||
timeout: float = 5.0,
|
||||
check_mpv: bool = True,
|
||||
) -> None:
|
||||
|
||||
ok, reason = _check_mpv_availability()
|
||||
if not ok:
|
||||
raise MPVIPCError(reason or "MPV unavailable")
|
||||
if bool(check_mpv):
|
||||
ok, reason = _check_mpv_availability()
|
||||
if not ok:
|
||||
raise MPVIPCError(reason or "MPV unavailable")
|
||||
|
||||
self.timeout = timeout
|
||||
self.ipc_path = ipc_path or get_ipc_pipe_path()
|
||||
@@ -246,6 +248,66 @@ class MPV:
|
||||
resp = self.send({"command": ["set_property", name, value]})
|
||||
return bool(resp and resp.get("error") == "success")
|
||||
|
||||
def download(
|
||||
self,
|
||||
*,
|
||||
url: str,
|
||||
fmt: str,
|
||||
store: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Download a URL using the same pipeline semantics as the MPV UI.
|
||||
|
||||
This is intended as a stable Python entrypoint for "button actions".
|
||||
It does not require mpv.exe availability (set check_mpv=False if needed).
|
||||
"""
|
||||
url = str(url or "").strip()
|
||||
fmt = str(fmt or "").strip()
|
||||
store = str(store or "").strip() if store is not None else None
|
||||
path = str(path or "").strip() if path is not None else None
|
||||
|
||||
if not url:
|
||||
return {"success": False, "stdout": "", "stderr": "", "error": "Missing url"}
|
||||
if not fmt:
|
||||
return {"success": False, "stdout": "", "stderr": "", "error": "Missing fmt"}
|
||||
if bool(store) == bool(path):
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": "Provide exactly one of store or path",
|
||||
}
|
||||
|
||||
# Ensure any in-process cmdlets that talk to MPV pick up this IPC path.
|
||||
try:
|
||||
os.environ["MEDEIA_MPV_IPC"] = str(self.ipc_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _q(s: str) -> str:
|
||||
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
|
||||
|
||||
pipeline = f"download-media -url {_q(url)} -format {_q(fmt)}"
|
||||
if store:
|
||||
pipeline += f" | add-file -store {_q(store)}"
|
||||
else:
|
||||
pipeline += f" | add-file -path {_q(path or '')}"
|
||||
|
||||
try:
|
||||
from TUI.pipeline_runner import PipelineExecutor # noqa: WPS433
|
||||
|
||||
executor = PipelineExecutor()
|
||||
result = executor.run_pipeline(pipeline)
|
||||
return {
|
||||
"success": bool(getattr(result, "success", False)),
|
||||
"stdout": getattr(result, "stdout", "") or "",
|
||||
"stderr": getattr(result, "stderr", "") or "",
|
||||
"error": getattr(result, "error", None),
|
||||
"pipeline": pipeline,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"success": False, "stdout": "", "stderr": "", "error": f"{type(exc).__name__}: {exc}", "pipeline": pipeline}
|
||||
|
||||
def get_playlist(self, silent: bool = False) -> Optional[List[Dict[str, Any]]]:
|
||||
resp = self.send({"command": ["get_property", "playlist"], "request_id": 100}, silent=silent)
|
||||
if resp is None:
|
||||
@@ -427,13 +489,62 @@ class MPV:
|
||||
http_header_fields: Optional[str] = None,
|
||||
detached: bool = True,
|
||||
) -> None:
|
||||
# uosc reads its config from "~~/script-opts/uosc.conf".
|
||||
# With --no-config, mpv makes ~~ expand to an empty string, so uosc can't load.
|
||||
# Instead, point mpv at a repo-controlled config directory.
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
except Exception:
|
||||
repo_root = Path.cwd()
|
||||
|
||||
portable_config_dir = repo_root / "MPV" / "portable_config"
|
||||
try:
|
||||
(portable_config_dir / "script-opts").mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure uosc.conf is available at the location uosc expects.
|
||||
try:
|
||||
src_uosc_conf = repo_root / "MPV" / "LUA" / "uosc" / "uosc.conf"
|
||||
dst_uosc_conf = portable_config_dir / "script-opts" / "uosc.conf"
|
||||
if src_uosc_conf.exists():
|
||||
# Only seed a default config if the user doesn't already have one.
|
||||
if not dst_uosc_conf.exists():
|
||||
dst_uosc_conf.write_bytes(src_uosc_conf.read_bytes())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd: List[str] = [
|
||||
"mpv",
|
||||
f"--config-dir={str(portable_config_dir)}",
|
||||
# Allow mpv to auto-load scripts from <config-dir>/scripts/ (e.g., thumbfast).
|
||||
"--load-scripts=yes",
|
||||
"--osc=no",
|
||||
"--load-console=no",
|
||||
"--load-commands=no",
|
||||
"--load-select=no",
|
||||
"--load-context-menu=no",
|
||||
"--load-positioning=no",
|
||||
"--load-stats-overlay=no",
|
||||
"--load-auto-profiles=no",
|
||||
"--ytdl=yes",
|
||||
f"--input-ipc-server={self.ipc_path}",
|
||||
"--idle=yes",
|
||||
"--force-window=yes",
|
||||
]
|
||||
|
||||
# uosc and other scripts are expected to be auto-loaded from portable_config/scripts.
|
||||
# We keep the back-compat fallback only if the user hasn't installed uosc.lua there.
|
||||
try:
|
||||
uosc_entry = portable_config_dir / "scripts" / "uosc.lua"
|
||||
if not uosc_entry.exists() and self.lua_script_path:
|
||||
lua_dir = Path(self.lua_script_path).resolve().parent
|
||||
uosc_main = lua_dir / "uosc" / "scripts" / "uosc" / "main.lua"
|
||||
if uosc_main.exists():
|
||||
cmd.append(f"--script={str(uosc_main)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Always load the bundled Lua script at startup.
|
||||
if self.lua_script_path and os.path.exists(self.lua_script_path):
|
||||
cmd.append(f"--script={self.lua_script_path}")
|
||||
@@ -519,6 +630,112 @@ class MPVIPCClient:
|
||||
self.sock: socket.socket | BinaryIO | None = None
|
||||
self.is_windows = platform.system() == "Windows"
|
||||
self.silent = bool(silent)
|
||||
self._recv_buffer: bytes = b""
|
||||
|
||||
def _write_payload(self, payload: str) -> None:
|
||||
if not self.sock:
|
||||
if not self.connect():
|
||||
raise MPVIPCError("Not connected")
|
||||
|
||||
if self.is_windows:
|
||||
pipe = cast(BinaryIO, self.sock)
|
||||
pipe.write(payload.encode("utf-8"))
|
||||
pipe.flush()
|
||||
else:
|
||||
sock_obj = cast(socket.socket, self.sock)
|
||||
sock_obj.sendall(payload.encode("utf-8"))
|
||||
|
||||
def _readline(self, *, timeout: Optional[float] = None) -> Optional[bytes]:
|
||||
if not self.sock:
|
||||
if not self.connect():
|
||||
return None
|
||||
|
||||
effective_timeout = self.timeout if timeout is None else float(timeout)
|
||||
deadline = _time.time() + max(0.0, effective_timeout)
|
||||
|
||||
if self.is_windows:
|
||||
try:
|
||||
pipe = cast(BinaryIO, self.sock)
|
||||
return pipe.readline()
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
|
||||
# Unix: buffer until newline.
|
||||
sock_obj = cast(socket.socket, self.sock)
|
||||
while True:
|
||||
nl = self._recv_buffer.find(b"\n")
|
||||
if nl != -1:
|
||||
line = self._recv_buffer[: nl + 1]
|
||||
self._recv_buffer = self._recv_buffer[nl + 1 :]
|
||||
return line
|
||||
|
||||
remaining = deadline - _time.time()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Temporarily narrow timeout for this read.
|
||||
old_timeout = sock_obj.gettimeout()
|
||||
try:
|
||||
sock_obj.settimeout(remaining)
|
||||
chunk = sock_obj.recv(4096)
|
||||
finally:
|
||||
sock_obj.settimeout(old_timeout)
|
||||
except socket.timeout:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not chunk:
|
||||
# EOF
|
||||
return b""
|
||||
self._recv_buffer += chunk
|
||||
|
||||
def read_message(self, *, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Read the next JSON message/event from MPV.
|
||||
|
||||
Returns:
|
||||
- dict: parsed JSON message/event
|
||||
- {"event": "__eof__"} if the stream ended
|
||||
- None on timeout / no data
|
||||
"""
|
||||
raw = self._readline(timeout=timeout)
|
||||
if raw is None:
|
||||
return None
|
||||
if raw == b"":
|
||||
return {"event": "__eof__"}
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8", errors="replace").strip())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def send_command_no_wait(self, command_data: Dict[str, Any] | List[Any]) -> Optional[int]:
|
||||
"""Send a command to mpv without waiting for its response.
|
||||
|
||||
This is important for long-running event loops (helpers) so we don't
|
||||
consume/lose async events (like property-change) while waiting.
|
||||
"""
|
||||
try:
|
||||
request: Dict[str, Any]
|
||||
if isinstance(command_data, list):
|
||||
request = {"command": command_data}
|
||||
else:
|
||||
request = dict(command_data)
|
||||
|
||||
if "request_id" not in request:
|
||||
request["request_id"] = int(_time.time() * 1000) % 100000
|
||||
|
||||
payload = json.dumps(request) + "\n"
|
||||
self._write_payload(payload)
|
||||
return int(request["request_id"])
|
||||
except Exception as exc:
|
||||
if not self.silent:
|
||||
debug(f"Error sending no-wait command to MPV: {exc}")
|
||||
try:
|
||||
self.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Connect to mpv IPC socket.
|
||||
@@ -592,44 +809,22 @@ class MPVIPCClient:
|
||||
_debug(f"[IPC] Sending: {payload.strip()}")
|
||||
|
||||
# Send command
|
||||
if self.is_windows:
|
||||
pipe = cast(BinaryIO, self.sock)
|
||||
pipe.write(payload.encode("utf-8"))
|
||||
pipe.flush()
|
||||
else:
|
||||
sock_obj = cast(socket.socket, self.sock)
|
||||
sock_obj.sendall(payload.encode("utf-8"))
|
||||
self._write_payload(payload)
|
||||
|
||||
# Receive response
|
||||
# We need to read lines until we find the one with matching request_id
|
||||
# or until timeout/error. MPV might send events in between.
|
||||
start_time = _time.time()
|
||||
while _time.time() - start_time < self.timeout:
|
||||
response_data = b""
|
||||
if self.is_windows:
|
||||
try:
|
||||
pipe = cast(BinaryIO, self.sock)
|
||||
response_data = pipe.readline()
|
||||
except (OSError, IOError):
|
||||
return None
|
||||
else:
|
||||
try:
|
||||
# This is simplistic for Unix socket (might not get full line)
|
||||
# But for now assuming MPV sends line-buffered JSON
|
||||
sock_obj = cast(socket.socket, self.sock)
|
||||
chunk = sock_obj.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response_data = chunk
|
||||
# TODO: Handle partial lines if needed
|
||||
except socket.timeout:
|
||||
return None
|
||||
response_data = self._readline(timeout=self.timeout)
|
||||
if response_data is None:
|
||||
return None
|
||||
|
||||
if not response_data:
|
||||
break
|
||||
|
||||
try:
|
||||
lines = response_data.decode('utf-8').strip().split('\n')
|
||||
lines = response_data.decode('utf-8', errors='replace').strip().split('\n')
|
||||
for line in lines:
|
||||
if not line: continue
|
||||
resp = json.loads(line)
|
||||
|
||||
@@ -17,15 +17,18 @@ Protocol (user-data properties):
|
||||
|
||||
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
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
@@ -40,7 +43,9 @@ if _ROOT not in sys.path:
|
||||
sys.path.insert(0, _ROOT)
|
||||
|
||||
|
||||
from SYS.tasks import connect_ipc # noqa: E402
|
||||
from MPV.mpv_ipc import MPVIPCClient # noqa: E402
|
||||
from 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"
|
||||
@@ -49,68 +54,361 @@ 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
|
||||
|
||||
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()
|
||||
|
||||
# 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"}:
|
||||
# Preferred: call the same choice function used by the CLI completer.
|
||||
try:
|
||||
from CLI import get_store_choices # noqa: WPS433
|
||||
|
||||
backends = get_store_choices()
|
||||
choices = sorted({str(n) for n in (backends or []) if str(n).strip()})
|
||||
except Exception:
|
||||
# Fallback: direct Store registry enumeration using loaded config.
|
||||
try:
|
||||
cfg = load_config() or {}
|
||||
except Exception:
|
||||
cfg = {}
|
||||
try:
|
||||
from Store import Store # noqa: WPS433
|
||||
|
||||
storage = Store(cfg, suppress_debug=True)
|
||||
backends = storage.list_backends() or []
|
||||
choices = sorted({str(n) for n in backends if str(n).strip()})
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
"table": None,
|
||||
"choices": [],
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": None,
|
||||
"table": None,
|
||||
"choices": 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 SYS.download 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)
|
||||
|
||||
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
|
||||
@@ -131,14 +429,87 @@ def _parse_request(data: Any) -> Optional[Dict[str, Any]]:
|
||||
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)
|
||||
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)
|
||||
|
||||
error_log_dir = Path(tempfile.gettempdir())
|
||||
# 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}")
|
||||
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]:
|
||||
@@ -164,33 +535,131 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
|
||||
return str(stamped) if stamped else str(last_error_log)
|
||||
|
||||
wire = MPVWire(args.ipc, timeout=float(args.timeout))
|
||||
if not wire.connect():
|
||||
return 2
|
||||
# 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
|
||||
|
||||
# Mark ready ASAP.
|
||||
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", READY_PROP, str(int(now))])
|
||||
last_ready_ts = now
|
||||
except Exception:
|
||||
return
|
||||
|
||||
_touch_ready()
|
||||
|
||||
# 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:
|
||||
wire.set_property(READY_PROP, "1")
|
||||
level = "debug" if debug_enabled else "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:
|
||||
wire.observe_property(OBS_ID_REQUEST, REQUEST_PROP, "string")
|
||||
client.send_command_no_wait(["observe_property", OBS_ID_REQUEST, REQUEST_PROP, "string"])
|
||||
except Exception:
|
||||
return 3
|
||||
|
||||
last_seen_id: Optional[str] = None
|
||||
|
||||
try:
|
||||
_append_helper_log(f"[helper] connected to ipc={args.ipc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while True:
|
||||
msg = wire.read_message()
|
||||
msg = client.read_message(timeout=0.25)
|
||||
if msg is None:
|
||||
time.sleep(0.05)
|
||||
# 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
|
||||
|
||||
@@ -202,10 +671,12 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
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 or not pipeline_text:
|
||||
if not req_id:
|
||||
continue
|
||||
|
||||
if last_seen_id == req_id:
|
||||
@@ -213,14 +684,29 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
last_seen_id = req_id
|
||||
|
||||
try:
|
||||
run = _run_pipeline(pipeline_text, seeds=seeds)
|
||||
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,
|
||||
@@ -228,8 +714,20 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
"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"):
|
||||
@@ -241,7 +739,9 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
resp["log_path"] = log_path
|
||||
|
||||
try:
|
||||
wire.set_property(RESPONSE_PROP, json.dumps(resp, ensure_ascii=False))
|
||||
# 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", RESPONSE_PROP, json.dumps(resp, ensure_ascii=False)])
|
||||
except Exception:
|
||||
# If posting results fails, there's nothing more useful to do.
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user