hkjh
This commit is contained in:
239
MPV/LUA/main.lua
239
MPV/LUA/main.lua
@@ -4,6 +4,35 @@ local msg = require 'mp.msg'
|
||||
|
||||
local M = {}
|
||||
|
||||
local LOAD_URL_MENU_TYPE = 'medios_load_url'
|
||||
|
||||
local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request'
|
||||
local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response'
|
||||
local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready'
|
||||
|
||||
local function write_temp_log(prefix, text)
|
||||
if not text or text == '' then
|
||||
return nil
|
||||
end
|
||||
local dir = os.getenv('TEMP') or os.getenv('TMP') or utils.getcwd() or ''
|
||||
if dir == '' then
|
||||
return nil
|
||||
end
|
||||
local name = (prefix or 'medeia-mpv') .. '-' .. tostring(math.floor(mp.get_time() * 1000)) .. '.log'
|
||||
local path = utils.join_path(dir, name)
|
||||
local fh = io.open(path, 'w')
|
||||
if not fh then
|
||||
return nil
|
||||
end
|
||||
fh:write(text)
|
||||
fh:close()
|
||||
return path
|
||||
end
|
||||
|
||||
local function trim(s)
|
||||
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
|
||||
end
|
||||
|
||||
-- Lyrics overlay toggle
|
||||
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
|
||||
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
||||
@@ -35,6 +64,132 @@ local opts = {
|
||||
cli_path = nil -- Will be auto-detected if nil
|
||||
}
|
||||
|
||||
local function find_file_upwards(start_dir, relative_path, max_levels)
|
||||
local dir = start_dir
|
||||
local levels = max_levels or 6
|
||||
for _ = 0, levels do
|
||||
if dir and dir ~= "" then
|
||||
local candidate = dir .. "/" .. relative_path
|
||||
if utils.file_info(candidate) then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
local parent = dir and dir:match("(.*)[/\\]") or nil
|
||||
if not parent or parent == dir or parent == "" then
|
||||
break
|
||||
end
|
||||
dir = parent
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local _pipeline_helper_started = false
|
||||
|
||||
local function get_mpv_ipc_path()
|
||||
local ipc = mp.get_property('input-ipc-server')
|
||||
if ipc and ipc ~= '' then
|
||||
return ipc
|
||||
end
|
||||
-- Fallback: fixed pipe/socket name used by MPV/mpv_ipc.py
|
||||
local sep = package and package.config and package.config:sub(1, 1) or '/'
|
||||
if sep == '\\' then
|
||||
return '\\\\.\\pipe\\mpv-medeia-macina'
|
||||
end
|
||||
return '/tmp/mpv-medeia-macina.sock'
|
||||
end
|
||||
|
||||
local function ensure_pipeline_helper_running()
|
||||
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||
if ready then
|
||||
return true
|
||||
end
|
||||
if _pipeline_helper_started then
|
||||
return true
|
||||
end
|
||||
|
||||
local base_dir = mp.get_script_directory() or ""
|
||||
if base_dir == "" then
|
||||
base_dir = utils.getcwd() or ""
|
||||
end
|
||||
local helper_path = find_file_upwards(base_dir, 'MPV/pipeline_helper.py', 6)
|
||||
if not helper_path then
|
||||
return false
|
||||
end
|
||||
|
||||
_pipeline_helper_started = true
|
||||
|
||||
local args = {opts.python_path, helper_path, '--ipc', get_mpv_ipc_path()}
|
||||
local ok = utils.subprocess_detached({ args = args })
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds)
|
||||
if not ensure_pipeline_helper_running() then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Avoid a race where we send the request before the helper has connected
|
||||
-- and installed its property observer, which would cause a timeout and
|
||||
-- force a noisy CLI fallback.
|
||||
do
|
||||
local deadline = mp.get_time() + 1.0
|
||||
while mp.get_time() < deadline do
|
||||
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||
if ready and tostring(ready) ~= '' and tostring(ready) ~= '0' then
|
||||
break
|
||||
end
|
||||
mp.wait_event(0.05)
|
||||
end
|
||||
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||
if not (ready and tostring(ready) ~= '' and tostring(ready) ~= '0') then
|
||||
_pipeline_helper_started = false
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
local id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
|
||||
local req = { id = id, pipeline = pipeline_cmd }
|
||||
if seeds then
|
||||
req.seeds = seeds
|
||||
end
|
||||
|
||||
-- Clear any previous response to reduce chances of reading stale data.
|
||||
mp.set_property(PIPELINE_RESP_PROP, '')
|
||||
mp.set_property(PIPELINE_REQ_PROP, utils.format_json(req))
|
||||
|
||||
local deadline = mp.get_time() + (timeout_seconds or 5)
|
||||
while mp.get_time() < deadline do
|
||||
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
||||
if resp_json and resp_json ~= '' then
|
||||
local ok, resp = pcall(utils.parse_json, resp_json)
|
||||
if ok and resp and resp.id == id then
|
||||
if resp.success then
|
||||
return resp.stdout or ''
|
||||
end
|
||||
local details = ''
|
||||
if resp.error and tostring(resp.error) ~= '' then
|
||||
details = tostring(resp.error)
|
||||
end
|
||||
if resp.stderr and tostring(resp.stderr) ~= '' then
|
||||
if details ~= '' then
|
||||
details = details .. "\n"
|
||||
end
|
||||
details = details .. tostring(resp.stderr)
|
||||
end
|
||||
local log_path = resp.log_path
|
||||
if log_path and tostring(log_path) ~= '' then
|
||||
details = (details ~= '' and (details .. "\n") or '') .. 'Log: ' .. tostring(log_path)
|
||||
end
|
||||
return nil, (details ~= '' and details or 'unknown')
|
||||
end
|
||||
end
|
||||
mp.wait_event(0.05)
|
||||
end
|
||||
-- Helper may have crashed or never started; allow retry on next call.
|
||||
_pipeline_helper_started = false
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Detect CLI path
|
||||
local function detect_script_dir()
|
||||
local dir = mp.get_script_directory()
|
||||
@@ -58,23 +213,30 @@ end
|
||||
|
||||
local script_dir = detect_script_dir() or ""
|
||||
if not opts.cli_path then
|
||||
-- Assuming the structure is repo/LUA/script.lua and repo/CLI.py
|
||||
-- We need to go up one level
|
||||
local parent_dir = script_dir:match("(.*)[/\\]")
|
||||
if parent_dir and parent_dir ~= "" then
|
||||
opts.cli_path = parent_dir .. "/CLI.py"
|
||||
else
|
||||
opts.cli_path = "CLI.py" -- Fallback
|
||||
end
|
||||
-- Try to locate CLI.py by walking up from this script directory.
|
||||
-- Typical layout here is: <repo>/MPV/LUA/main.lua, and <repo>/CLI.py
|
||||
opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py"
|
||||
end
|
||||
|
||||
-- Helper to run pipeline
|
||||
function M.run_pipeline(pipeline_cmd, seeds)
|
||||
local args = {opts.python_path, opts.cli_path, "pipeline", pipeline_cmd}
|
||||
local out, err = run_pipeline_via_ipc(pipeline_cmd, seeds, 5)
|
||||
if out ~= nil then
|
||||
return out
|
||||
end
|
||||
if err ~= nil then
|
||||
local log_path = write_temp_log('medeia-pipeline-error', tostring(err))
|
||||
local suffix = log_path and (' (log: ' .. log_path .. ')') or ''
|
||||
msg.error('Pipeline error: ' .. tostring(err) .. suffix)
|
||||
mp.osd_message('Error: pipeline failed' .. suffix, 6)
|
||||
return nil
|
||||
end
|
||||
|
||||
local args = {opts.python_path, opts.cli_path, "pipeline", "--pipeline", pipeline_cmd}
|
||||
|
||||
if seeds then
|
||||
local seeds_json = utils.format_json(seeds)
|
||||
table.insert(args, "--seeds")
|
||||
table.insert(args, "--seeds-json")
|
||||
table.insert(args, seeds_json)
|
||||
end
|
||||
|
||||
@@ -85,8 +247,13 @@ function M.run_pipeline(pipeline_cmd, seeds)
|
||||
})
|
||||
|
||||
if res.status ~= 0 then
|
||||
msg.error("Pipeline error: " .. (res.stderr or "unknown"))
|
||||
mp.osd_message("Error: " .. (res.stderr or "unknown"), 5)
|
||||
local err = (res.stderr and res.stderr ~= "") and res.stderr
|
||||
or (res.error_string and res.error_string ~= "") and res.error_string
|
||||
or "unknown"
|
||||
local log_path = write_temp_log('medeia-cli-pipeline-stderr', tostring(res.stderr or err))
|
||||
local suffix = log_path and (' (log: ' .. log_path .. ')') or ''
|
||||
msg.error("Pipeline error: " .. err .. suffix)
|
||||
mp.osd_message("Error: pipeline failed" .. suffix, 6)
|
||||
return nil
|
||||
end
|
||||
|
||||
@@ -143,6 +310,47 @@ function M.delete_current_file()
|
||||
mp.command("playlist-next")
|
||||
end
|
||||
|
||||
-- Command: Load a URL via pipeline (Ctrl+Enter in prompt)
|
||||
function M.open_load_url_prompt()
|
||||
local menu_data = {
|
||||
type = LOAD_URL_MENU_TYPE,
|
||||
title = 'Load URL',
|
||||
search_style = 'palette',
|
||||
search_debounce = 'submit',
|
||||
on_search = 'callback',
|
||||
footnote = 'Paste/type URL, then Ctrl+Enter to load.',
|
||||
callback = {mp.get_script_name(), 'medios-load-url-event'},
|
||||
items = {},
|
||||
}
|
||||
|
||||
local json = utils.format_json(menu_data)
|
||||
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
|
||||
end
|
||||
|
||||
mp.register_script_message('medios-load-url', function()
|
||||
M.open_load_url_prompt()
|
||||
end)
|
||||
|
||||
mp.register_script_message('medios-load-url-event', function(json)
|
||||
local ok, event = pcall(utils.parse_json, json)
|
||||
if not ok or type(event) ~= 'table' then
|
||||
return
|
||||
end
|
||||
if event.type ~= 'search' then
|
||||
return
|
||||
end
|
||||
|
||||
local url = trim(tostring(event.query or ''))
|
||||
if url == '' then
|
||||
return
|
||||
end
|
||||
|
||||
local out = M.run_pipeline('.pipe ' .. url .. ' -play')
|
||||
if out ~= nil then
|
||||
mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Menu integration with UOSC
|
||||
function M.show_menu()
|
||||
local menu_data = {
|
||||
@@ -150,6 +358,7 @@ function M.show_menu()
|
||||
items = {
|
||||
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
|
||||
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
|
||||
{ title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,4 +376,10 @@ mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file)
|
||||
mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle)
|
||||
mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle)
|
||||
|
||||
-- Start the persistent pipeline helper eagerly at launch.
|
||||
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
|
||||
mp.add_timeout(0, function()
|
||||
pcall(ensure_pipeline_helper_running)
|
||||
end)
|
||||
|
||||
return M
|
||||
|
||||
@@ -474,6 +474,10 @@ def get_ipc_pipe_path() -> str:
|
||||
Returns:
|
||||
Path to IPC pipe (Windows) or socket (Linux/macOS)
|
||||
"""
|
||||
override = os.environ.get("MEDEIA_MPV_IPC") or os.environ.get("MPV_IPC_SERVER")
|
||||
if override:
|
||||
return str(override)
|
||||
|
||||
system = platform.system()
|
||||
|
||||
if system == "Windows":
|
||||
|
||||
251
MPV/pipeline_helper.py
Normal file
251
MPV/pipeline_helper.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user