hkjh
This commit is contained in:
8
CLI.py
8
CLI.py
@@ -856,6 +856,14 @@ def _create_cmdlet_cli():
|
|||||||
stream=sys.stderr
|
stream=sys.stderr
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# httpx/httpcore can be extremely verbose and will drown useful debug output,
|
||||||
|
# especially when invoked from MPV (where console output is truncated).
|
||||||
|
for noisy in ("httpx", "httpcore", "httpcore.http11", "httpcore.connection"):
|
||||||
|
try:
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Handle seeds if provided
|
# Handle seeds if provided
|
||||||
if seeds_json:
|
if seeds_json:
|
||||||
try:
|
try:
|
||||||
|
|||||||
239
MPV/LUA/main.lua
239
MPV/LUA/main.lua
@@ -4,6 +4,35 @@ local msg = require 'mp.msg'
|
|||||||
|
|
||||||
local M = {}
|
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
|
-- Lyrics overlay toggle
|
||||||
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
|
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
|
||||||
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
||||||
@@ -35,6 +64,132 @@ local opts = {
|
|||||||
cli_path = nil -- Will be auto-detected if nil
|
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
|
-- Detect CLI path
|
||||||
local function detect_script_dir()
|
local function detect_script_dir()
|
||||||
local dir = mp.get_script_directory()
|
local dir = mp.get_script_directory()
|
||||||
@@ -58,23 +213,30 @@ end
|
|||||||
|
|
||||||
local script_dir = detect_script_dir() or ""
|
local script_dir = detect_script_dir() or ""
|
||||||
if not opts.cli_path then
|
if not opts.cli_path then
|
||||||
-- Assuming the structure is repo/LUA/script.lua and repo/CLI.py
|
-- Try to locate CLI.py by walking up from this script directory.
|
||||||
-- We need to go up one level
|
-- Typical layout here is: <repo>/MPV/LUA/main.lua, and <repo>/CLI.py
|
||||||
local parent_dir = script_dir:match("(.*)[/\\]")
|
opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py"
|
||||||
if parent_dir and parent_dir ~= "" then
|
|
||||||
opts.cli_path = parent_dir .. "/CLI.py"
|
|
||||||
else
|
|
||||||
opts.cli_path = "CLI.py" -- Fallback
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Helper to run pipeline
|
-- Helper to run pipeline
|
||||||
function M.run_pipeline(pipeline_cmd, seeds)
|
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
|
if seeds then
|
||||||
local seeds_json = utils.format_json(seeds)
|
local seeds_json = utils.format_json(seeds)
|
||||||
table.insert(args, "--seeds")
|
table.insert(args, "--seeds-json")
|
||||||
table.insert(args, seeds_json)
|
table.insert(args, seeds_json)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -85,8 +247,13 @@ function M.run_pipeline(pipeline_cmd, seeds)
|
|||||||
})
|
})
|
||||||
|
|
||||||
if res.status ~= 0 then
|
if res.status ~= 0 then
|
||||||
msg.error("Pipeline error: " .. (res.stderr or "unknown"))
|
local err = (res.stderr and res.stderr ~= "") and res.stderr
|
||||||
mp.osd_message("Error: " .. (res.stderr or "unknown"), 5)
|
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
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -143,6 +310,47 @@ function M.delete_current_file()
|
|||||||
mp.command("playlist-next")
|
mp.command("playlist-next")
|
||||||
end
|
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
|
-- Menu integration with UOSC
|
||||||
function M.show_menu()
|
function M.show_menu()
|
||||||
local menu_data = {
|
local menu_data = {
|
||||||
@@ -150,6 +358,7 @@ function M.show_menu()
|
|||||||
items = {
|
items = {
|
||||||
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
|
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
|
||||||
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
|
{ 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", lyric_toggle)
|
||||||
mp.add_key_binding("L", "medeia-lyric-toggle-shift", 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
|
return M
|
||||||
|
|||||||
@@ -474,6 +474,10 @@ def get_ipc_pipe_path() -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
Path to IPC pipe (Windows) or socket (Linux/macOS)
|
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()
|
system = platform.system()
|
||||||
|
|
||||||
if system == "Windows":
|
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())
|
||||||
@@ -100,6 +100,7 @@ class PipelineExecutor:
|
|||||||
self,
|
self,
|
||||||
pipeline_text: str,
|
pipeline_text: str,
|
||||||
*,
|
*,
|
||||||
|
seeds: Optional[Any] = None,
|
||||||
on_log: Optional[Callable[[str], None]] = None,
|
on_log: Optional[Callable[[str], None]] = None,
|
||||||
) -> PipelineRunResult:
|
) -> PipelineRunResult:
|
||||||
"""Execute a pipeline string and return structured results.
|
"""Execute a pipeline string and return structured results.
|
||||||
@@ -123,6 +124,19 @@ class PipelineExecutor:
|
|||||||
ctx.reset()
|
ctx.reset()
|
||||||
ctx.set_current_command_text(normalized)
|
ctx.set_current_command_text(normalized)
|
||||||
|
|
||||||
|
if seeds is not None:
|
||||||
|
try:
|
||||||
|
# Mirror CLI behavior: treat seeds as output of a virtual previous stage.
|
||||||
|
if not isinstance(seeds, list):
|
||||||
|
seeds = [seeds]
|
||||||
|
setter = getattr(ctx, "set_last_result_items_only", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(seeds)
|
||||||
|
else:
|
||||||
|
ctx.set_last_items(list(seeds))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
stdout_buffer = io.StringIO()
|
stdout_buffer = io.StringIO()
|
||||||
stderr_buffer = io.StringIO()
|
stderr_buffer = io.StringIO()
|
||||||
piped_result: Any = None
|
piped_result: Any = None
|
||||||
|
|||||||
@@ -323,6 +323,16 @@ def _capture(options: ScreenshotOptions, destination: Path, warnings: List[str])
|
|||||||
debug(f"[_capture] Starting capture for {options.url} -> {destination}")
|
debug(f"[_capture] Starting capture for {options.url} -> {destination}")
|
||||||
try:
|
try:
|
||||||
tool = options.playwright_tool or PlaywrightTool({})
|
tool = options.playwright_tool or PlaywrightTool({})
|
||||||
|
|
||||||
|
# Ensure Chromium engine is used for the screen-shot cmdlet (force for consistency)
|
||||||
|
try:
|
||||||
|
current_browser = getattr(tool.defaults, "browser", "").lower() if getattr(tool, "defaults", None) is not None else ""
|
||||||
|
if current_browser != "chromium":
|
||||||
|
debug(f"[_capture] Overriding Playwright browser '{current_browser}' -> 'chromium' for screen-shot cmdlet")
|
||||||
|
tool = PlaywrightTool({"tool": {"playwright": {"browser": "chromium"}}})
|
||||||
|
except Exception:
|
||||||
|
tool = PlaywrightTool({"tool": {"playwright": {"browser": "chromium"}}})
|
||||||
|
|
||||||
tool.debug_dump()
|
tool.debug_dump()
|
||||||
|
|
||||||
log("Launching browser...", flush=True)
|
log("Launching browser...", flush=True)
|
||||||
@@ -333,104 +343,114 @@ def _capture(options: ScreenshotOptions, destination: Path, warnings: List[str])
|
|||||||
if format_name == "pdf" and not options.headless:
|
if format_name == "pdf" and not options.headless:
|
||||||
warnings.append("pdf output requires headless Chromium; overriding headless mode")
|
warnings.append("pdf output requires headless Chromium; overriding headless mode")
|
||||||
|
|
||||||
with tool.open_page(headless=headless) as page:
|
try:
|
||||||
log(f"Navigating to {options.url}...", flush=True)
|
with tool.open_page(headless=headless) as page:
|
||||||
try:
|
log(f"Navigating to {options.url}...", flush=True)
|
||||||
tool.goto(page, options.url)
|
|
||||||
log("Page loaded successfully", flush=True)
|
|
||||||
except PlaywrightTimeoutError:
|
|
||||||
warnings.append("navigation timeout; capturing current page state")
|
|
||||||
log("Navigation timeout; proceeding with current state", flush=True)
|
|
||||||
|
|
||||||
# Skip article lookup by default (wait_for_article defaults to False)
|
|
||||||
if options.wait_for_article:
|
|
||||||
try:
|
try:
|
||||||
log("Waiting for article element...", flush=True)
|
tool.goto(page, options.url)
|
||||||
page.wait_for_selector("article", timeout=10_000)
|
log("Page loaded successfully", flush=True)
|
||||||
log("Article element found", flush=True)
|
|
||||||
except PlaywrightTimeoutError:
|
except PlaywrightTimeoutError:
|
||||||
warnings.append("<article> selector not found; capturing fallback")
|
warnings.append("navigation timeout; capturing current page state")
|
||||||
log("Article element not found; using fallback", flush=True)
|
log("Navigation timeout; proceeding with current state", flush=True)
|
||||||
|
|
||||||
if options.wait_after_load > 0:
|
# Skip article lookup by default (wait_for_article defaults to False)
|
||||||
log(f"Waiting {options.wait_after_load}s for page stabilization...", flush=True)
|
if options.wait_for_article:
|
||||||
time.sleep(min(10.0, max(0.0, options.wait_after_load)))
|
|
||||||
if options.replace_video_posters:
|
|
||||||
log("Replacing video elements with posters...", flush=True)
|
|
||||||
page.evaluate(
|
|
||||||
"""
|
|
||||||
document.querySelectorAll('video').forEach(v => {
|
|
||||||
if (v.poster) {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = v.poster;
|
|
||||||
img.style.maxWidth = '100%';
|
|
||||||
img.style.borderRadius = '12px';
|
|
||||||
v.replaceWith(img);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
# Attempt platform-specific target capture if requested (and not PDF)
|
|
||||||
element_captured = False
|
|
||||||
if options.prefer_platform_target and format_name != "pdf":
|
|
||||||
log("Attempting platform-specific content capture...", flush=True)
|
|
||||||
try:
|
|
||||||
_platform_preprocess(options.url, page, warnings)
|
|
||||||
except Exception as e:
|
|
||||||
debug(f"[_capture] Platform preprocess failed: {e}")
|
|
||||||
pass
|
|
||||||
selectors = list(options.target_selectors or [])
|
|
||||||
if not selectors:
|
|
||||||
selectors = _selectors_for_url(options.url)
|
|
||||||
|
|
||||||
debug(f"[_capture] Trying selectors: {selectors}")
|
|
||||||
for sel in selectors:
|
|
||||||
try:
|
try:
|
||||||
log(f"Trying selector: {sel}", flush=True)
|
log("Waiting for article element...", flush=True)
|
||||||
el = page.wait_for_selector(sel, timeout=max(0, int(options.selector_timeout_ms)))
|
page.wait_for_selector("article", timeout=10_000)
|
||||||
|
log("Article element found", flush=True)
|
||||||
except PlaywrightTimeoutError:
|
except PlaywrightTimeoutError:
|
||||||
log(f"Selector not found: {sel}", flush=True)
|
warnings.append("<article> selector not found; capturing fallback")
|
||||||
continue
|
log("Article element not found; using fallback", flush=True)
|
||||||
|
|
||||||
|
if options.wait_after_load > 0:
|
||||||
|
log(f"Waiting {options.wait_after_load}s for page stabilization...", flush=True)
|
||||||
|
time.sleep(min(10.0, max(0.0, options.wait_after_load)))
|
||||||
|
if options.replace_video_posters:
|
||||||
|
log("Replacing video elements with posters...", flush=True)
|
||||||
|
page.evaluate(
|
||||||
|
"""
|
||||||
|
document.querySelectorAll('video').forEach(v => {
|
||||||
|
if (v.poster) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = v.poster;
|
||||||
|
img.style.maxWidth = '100%';
|
||||||
|
img.style.borderRadius = '12px';
|
||||||
|
v.replaceWith(img);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Attempt platform-specific target capture if requested (and not PDF)
|
||||||
|
element_captured = False
|
||||||
|
if options.prefer_platform_target and format_name != "pdf":
|
||||||
|
log("Attempting platform-specific content capture...", flush=True)
|
||||||
try:
|
try:
|
||||||
if el is not None:
|
_platform_preprocess(options.url, page, warnings)
|
||||||
log(f"Found element with selector: {sel}", flush=True)
|
except Exception as e:
|
||||||
try:
|
debug(f"[_capture] Platform preprocess failed: {e}")
|
||||||
el.scroll_into_view_if_needed(timeout=1000)
|
pass
|
||||||
except Exception:
|
selectors = list(options.target_selectors or [])
|
||||||
pass
|
if not selectors:
|
||||||
log(f"Capturing element to {destination}...", flush=True)
|
selectors = _selectors_for_url(options.url)
|
||||||
el.screenshot(path=str(destination), type=("jpeg" if format_name == "jpeg" else None))
|
|
||||||
element_captured = True
|
debug(f"[_capture] Trying selectors: {selectors}")
|
||||||
log("Element captured successfully", flush=True)
|
for sel in selectors:
|
||||||
break
|
try:
|
||||||
except Exception as exc:
|
log(f"Trying selector: {sel}", flush=True)
|
||||||
warnings.append(f"element capture failed for '{sel}': {exc}")
|
el = page.wait_for_selector(sel, timeout=max(0, int(options.selector_timeout_ms)))
|
||||||
log(f"Failed to capture element: {exc}", flush=True)
|
except PlaywrightTimeoutError:
|
||||||
# Fallback to default capture paths
|
log(f"Selector not found: {sel}", flush=True)
|
||||||
if element_captured:
|
continue
|
||||||
pass
|
try:
|
||||||
elif format_name == "pdf":
|
if el is not None:
|
||||||
log("Generating PDF...", flush=True)
|
log(f"Found element with selector: {sel}", flush=True)
|
||||||
page.emulate_media(media="print")
|
try:
|
||||||
page.pdf(path=str(destination), print_background=True)
|
el.scroll_into_view_if_needed(timeout=1000)
|
||||||
log(f"PDF saved to {destination}", flush=True)
|
except Exception:
|
||||||
else:
|
pass
|
||||||
log(f"Capturing full page to {destination}...", flush=True)
|
log(f"Capturing element to {destination}...", flush=True)
|
||||||
screenshot_kwargs: Dict[str, Any] = {"path": str(destination)}
|
el.screenshot(path=str(destination), type=("jpeg" if format_name == "jpeg" else None))
|
||||||
if format_name == "jpeg":
|
element_captured = True
|
||||||
screenshot_kwargs["type"] = "jpeg"
|
log("Element captured successfully", flush=True)
|
||||||
screenshot_kwargs["quality"] = 90
|
break
|
||||||
if options.full_page:
|
except Exception as exc:
|
||||||
page.screenshot(full_page=True, **screenshot_kwargs)
|
warnings.append(f"element capture failed for '{sel}': {exc}")
|
||||||
|
log(f"Failed to capture element: {exc}", flush=True)
|
||||||
|
# Fallback to default capture paths
|
||||||
|
if element_captured:
|
||||||
|
pass
|
||||||
|
elif format_name == "pdf":
|
||||||
|
log("Generating PDF...", flush=True)
|
||||||
|
page.emulate_media(media="print")
|
||||||
|
page.pdf(path=str(destination), print_background=True)
|
||||||
|
log(f"PDF saved to {destination}", flush=True)
|
||||||
else:
|
else:
|
||||||
article = page.query_selector("article")
|
log(f"Capturing full page to {destination}...", flush=True)
|
||||||
if article is not None:
|
screenshot_kwargs: Dict[str, Any] = {"path": str(destination)}
|
||||||
article_kwargs = dict(screenshot_kwargs)
|
if format_name == "jpeg":
|
||||||
article_kwargs.pop("full_page", None)
|
screenshot_kwargs["type"] = "jpeg"
|
||||||
article.screenshot(**article_kwargs)
|
screenshot_kwargs["quality"] = 90
|
||||||
|
if options.full_page:
|
||||||
|
page.screenshot(full_page=True, **screenshot_kwargs)
|
||||||
else:
|
else:
|
||||||
page.screenshot(**screenshot_kwargs)
|
article = page.query_selector("article")
|
||||||
log(f"Screenshot saved to {destination}", flush=True)
|
if article is not None:
|
||||||
|
article_kwargs = dict(screenshot_kwargs)
|
||||||
|
article_kwargs.pop("full_page", None)
|
||||||
|
article.screenshot(**article_kwargs)
|
||||||
|
else:
|
||||||
|
page.screenshot(**screenshot_kwargs)
|
||||||
|
log(f"Screenshot saved to {destination}", flush=True)
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"[_capture] Exception launching browser/page: {exc}")
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if any(k in msg for k in ["executable", "not found", "no such file", "cannot find", "install"]):
|
||||||
|
raise ScreenshotError("Chromium Playwright browser binaries not found. Install them: python ./scripts/setup.py --playwright-only --browsers chromium") from exc
|
||||||
|
raise
|
||||||
|
except ScreenshotError:
|
||||||
|
# Re-raise ScreenshotError raised intentionally (do not wrap)
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug(f"[_capture] Exception: {exc}")
|
debug(f"[_capture] Exception: {exc}")
|
||||||
raise ScreenshotError(f"Failed to capture screenshot: {exc}") from exc
|
raise ScreenshotError(f"Failed to capture screenshot: {exc}") from exc
|
||||||
@@ -645,6 +665,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Create screenshot with provided options
|
# Create screenshot with provided options
|
||||||
|
# Force the Playwright engine to Chromium for the screen-shot cmdlet
|
||||||
|
# (this ensures consistent rendering and supports PDF output requirements).
|
||||||
|
pw_local_cfg = {}
|
||||||
|
if isinstance(config, dict):
|
||||||
|
tool_block = dict(config.get("tool") or {})
|
||||||
|
pw_block = dict(tool_block.get("playwright") or {})
|
||||||
|
pw_block["browser"] = "chromium"
|
||||||
|
tool_block["playwright"] = pw_block
|
||||||
|
pw_local_cfg = dict(config)
|
||||||
|
pw_local_cfg["tool"] = tool_block
|
||||||
|
else:
|
||||||
|
pw_local_cfg = {"tool": {"playwright": {"browser": "chromium"}}}
|
||||||
|
|
||||||
options = ScreenshotOptions(
|
options = ScreenshotOptions(
|
||||||
url=url,
|
url=url,
|
||||||
output_dir=screenshot_dir,
|
output_dir=screenshot_dir,
|
||||||
@@ -654,7 +687,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
prefer_platform_target=False,
|
prefer_platform_target=False,
|
||||||
wait_for_article=False,
|
wait_for_article=False,
|
||||||
full_page=True,
|
full_page=True,
|
||||||
playwright_tool=PlaywrightTool(config),
|
playwright_tool=PlaywrightTool(pw_local_cfg),
|
||||||
)
|
)
|
||||||
|
|
||||||
screenshot_result = _capture_screenshot(options)
|
screenshot_result = _capture_screenshot(options)
|
||||||
@@ -744,12 +777,11 @@ CMDLET = Cmdlet(
|
|||||||
CmdletArg(name="selector", type="string", description="CSS selector for element capture"),
|
CmdletArg(name="selector", type="string", description="CSS selector for element capture"),
|
||||||
|
|
||||||
],
|
],
|
||||||
detail=
|
detail=[
|
||||||
["""
|
"Uses Playwright Chromium engine only. Install Chromium with: python ./scripts/setup.py --playwright-only --browsers chromium",
|
||||||
|
"PDF output requires headless Chromium (the cmdlet will enforce headless mode for PDF).",
|
||||||
|
"Screenshots are temporary artifacts stored in the configured `temp` directory.",
|
||||||
|
]
|
||||||
"""]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
CMDLET.exec = _run
|
CMDLET.exec = _run
|
||||||
|
|||||||
@@ -869,13 +869,9 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
start_opts: Dict[str, Any] = {"borderless": borderless, "mpv_log_path": mpv_log_path}
|
start_opts: Dict[str, Any] = {"borderless": borderless, "mpv_log_path": mpv_log_path}
|
||||||
|
|
||||||
# Initialize Store registry for detecting Hydrus instance names
|
# Store registry is only needed for certain playlist listing/inference paths.
|
||||||
|
# Keep it lazy so a simple `.pipe <url> -play` doesn't trigger Hydrus/API calls.
|
||||||
file_storage = None
|
file_storage = None
|
||||||
try:
|
|
||||||
from Store import Store
|
|
||||||
file_storage = Store(config)
|
|
||||||
except Exception as e:
|
|
||||||
debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
# Initialize mpv_started flag
|
# Initialize mpv_started flag
|
||||||
mpv_started = False
|
mpv_started = False
|
||||||
@@ -1313,6 +1309,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
debug("MPV playlist is empty.")
|
debug("MPV playlist is empty.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if file_storage is None:
|
||||||
|
try:
|
||||||
|
from Store import Store
|
||||||
|
file_storage = Store(config)
|
||||||
|
except Exception as e:
|
||||||
|
debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr)
|
||||||
|
|
||||||
# Use the loaded playlist name if available, otherwise default
|
# Use the loaded playlist name if available, otherwise default
|
||||||
# Note: current_playlist_name is defined in the load_mode block if a playlist was loaded
|
# Note: current_playlist_name is defined in the load_mode block if a playlist was loaded
|
||||||
try:
|
try:
|
||||||
|
|||||||
10
readme.md
10
readme.md
@@ -18,11 +18,14 @@ python -m pip install -r requirements.txt
|
|||||||
# Automated setup (recommended): run the single Python setup script which installs
|
# Automated setup (recommended): run the single Python setup script which installs
|
||||||
# all Python dependencies (from requirements.txt) and downloads Playwright browsers.
|
# all Python dependencies (from requirements.txt) and downloads Playwright browsers.
|
||||||
# Usage:
|
# Usage:
|
||||||
python ./scripts/setup.py
|
# - Default: python ./scripts/setup.py # installs Chromium only (saves disk)
|
||||||
|
# - To install all Playwright engines: python ./scripts/setup.py --browsers all
|
||||||
|
|
||||||
# Advanced options:
|
# Advanced options:
|
||||||
# - Skip dependency installation: python ./scripts/setup.py --skip-deps
|
# - Skip dependency installation: python ./scripts/setup.py --skip-deps
|
||||||
# - Install only Playwright browsers: python ./scripts/setup.py --playwright-only
|
# - Install only Playwright browsers: python ./scripts/setup.py --playwright-only
|
||||||
|
# - Install only specific browsers (saves disk): python ./scripts/setup.py --browsers chromium
|
||||||
|
# - Example: install only Chromium browsers: python ./scripts/setup.py --playwright-only --browsers chromium
|
||||||
```
|
```
|
||||||
2. Copy or edit `config.conf` and set a required `temp` directory where intermediate files are written. Example:
|
2. Copy or edit `config.conf` and set a required `temp` directory where intermediate files are written. Example:
|
||||||
|
|
||||||
@@ -93,7 +96,10 @@ download-media [URL] | add-file -store hydrus
|
|||||||
|
|
||||||
## Troubleshooting & tips 🛠️
|
## Troubleshooting & tips 🛠️
|
||||||
- If a cmdlet complains about an unknown store, ensure the piped item has a valid local `path` or use `-store <name>` to target a configured backend.
|
- If a cmdlet complains about an unknown store, ensure the piped item has a valid local `path` or use `-store <name>` to target a configured backend.
|
||||||
- For Playwright screenshots, run `playwright install` after installing the package to download browser binaries.
|
- For Playwright screenshots, run `python ./scripts/setup.py` (installs Chromium by default to save download space). To install all engines, run `python ./scripts/setup.py --browsers all`.
|
||||||
|
- Note: the `screen-shot` cmdlet forces the Playwright **Chromium** engine and will not use Firefox or WebKit.
|
||||||
|
- To run tests locally after removing `tests/conftest.py`, install the project in editable mode first so tests can import the package: `python -m pip install -e .` or run `python ./scripts/setup.py --install-editable`.
|
||||||
|
- Deno: this setup script now **installs Deno by default**. To opt out, run `python ./scripts/setup.py --no-deno`. You can still pin a version: `python ./scripts/setup.py --deno-version v1.34.3`.
|
||||||
- Use `--debug` to enable verbose logs when tracking down an error.
|
- Use `--debug` to enable verbose logs when tracking down an error.
|
||||||
|
|
||||||
## Contributing & docs ✨
|
## Contributing & docs ✨
|
||||||
|
|||||||
114
scripts/setup.py
114
scripts/setup.py
@@ -5,6 +5,8 @@ Unified project setup helper (Python-only).
|
|||||||
|
|
||||||
This script installs Python dependencies from `requirements.txt` and then
|
This script installs Python dependencies from `requirements.txt` and then
|
||||||
downloads Playwright browser binaries by running `python -m playwright install`.
|
downloads Playwright browser binaries by running `python -m playwright install`.
|
||||||
|
By default this script installs **Chromium** only to conserve space; pass
|
||||||
|
`--browsers all` to install all supported engines (chromium, firefox, webkit).
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python ./scripts/setup.py # install deps and playwright browsers
|
python ./scripts/setup.py # install deps and playwright browsers
|
||||||
@@ -15,6 +17,10 @@ Optional flags:
|
|||||||
--skip-deps Skip `pip install -r requirements.txt` step
|
--skip-deps Skip `pip install -r requirements.txt` step
|
||||||
--no-playwright Skip running `python -m playwright install` (still installs deps)
|
--no-playwright Skip running `python -m playwright install` (still installs deps)
|
||||||
--playwright-only Install only Playwright browsers (installs playwright package if missing)
|
--playwright-only Install only Playwright browsers (installs playwright package if missing)
|
||||||
|
--browsers Comma-separated list of Playwright browsers to install (default: chromium)
|
||||||
|
--install-editable Install the project in editable mode (pip install -e .) for running tests
|
||||||
|
--install-deno Install the Deno runtime using the official installer
|
||||||
|
--deno-version Pin a specific Deno version to install (e.g., v1.34.3)
|
||||||
--upgrade-pip Upgrade pip, setuptools, and wheel before installing deps
|
--upgrade-pip Upgrade pip, setuptools, and wheel before installing deps
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -24,6 +30,8 @@ import argparse
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str]) -> None:
|
def run(cmd: list[str]) -> None:
|
||||||
@@ -40,11 +48,82 @@ def playwright_package_installed() -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_playwright_install_cmd(browsers: str | None) -> list[str]:
|
||||||
|
"""Return the command to install Playwright browsers.
|
||||||
|
|
||||||
|
- If browsers is None or empty: default to install Chromium only.
|
||||||
|
- If browsers contains 'all': install all engines by running 'playwright install' with no extra args.
|
||||||
|
- Otherwise, validate entries and return a command that installs the named engines.
|
||||||
|
"""
|
||||||
|
base = [sys.executable, "-m", "playwright", "install"]
|
||||||
|
if not browsers:
|
||||||
|
return base + ["chromium"]
|
||||||
|
|
||||||
|
items = [b.strip().lower() for b in browsers.split(",") if b.strip()]
|
||||||
|
if not items:
|
||||||
|
return base + ["chromium"]
|
||||||
|
if "all" in items:
|
||||||
|
return base
|
||||||
|
|
||||||
|
allowed = {"chromium", "firefox", "webkit"}
|
||||||
|
invalid = [b for b in items if b not in allowed]
|
||||||
|
if invalid:
|
||||||
|
raise ValueError(f"invalid browsers specified: {invalid}. Valid choices: chromium, firefox, webkit, or 'all'")
|
||||||
|
return base + items
|
||||||
|
|
||||||
|
|
||||||
|
def _install_deno(version: str | None = None) -> int:
|
||||||
|
"""Install Deno runtime for the current platform.
|
||||||
|
|
||||||
|
Uses the official Deno install scripts:
|
||||||
|
- Unix/macOS: curl -fsSL https://deno.land/x/install/install.sh | sh [-s <version>]
|
||||||
|
- Windows: powershell iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno [-Version <version>]
|
||||||
|
|
||||||
|
Returns exit code 0 on success, non-zero otherwise.
|
||||||
|
"""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if system == "windows":
|
||||||
|
# Use official PowerShell installer
|
||||||
|
if version:
|
||||||
|
ver = version if version.startswith("v") else f"v{version}"
|
||||||
|
ps_cmd = f"iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno -Version {ver}"
|
||||||
|
else:
|
||||||
|
ps_cmd = "iwr https://deno.land/x/install/install.ps1 -useb | iex"
|
||||||
|
run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd])
|
||||||
|
else:
|
||||||
|
# POSIX: use curl + sh installer
|
||||||
|
if version:
|
||||||
|
ver = version if version.startswith("v") else f"v{version}"
|
||||||
|
cmd = f"curl -fsSL https://deno.land/x/install/install.sh | sh -s {ver}"
|
||||||
|
else:
|
||||||
|
cmd = "curl -fsSL https://deno.land/x/install/install.sh | sh"
|
||||||
|
run(["sh", "-c", cmd])
|
||||||
|
|
||||||
|
# Check that 'deno' is now available in PATH
|
||||||
|
if shutil.which("deno"):
|
||||||
|
print(f"Deno installed at: {shutil.which('deno')}")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("Deno installation completed but 'deno' not found in PATH. You may need to add Deno's bin directory to your PATH manually.", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print(f"Deno install failed: {exc}", file=sys.stderr)
|
||||||
|
return int(exc.returncode or 1)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Setup Medios-Macina: install deps and Playwright browsers")
|
parser = argparse.ArgumentParser(description="Setup Medios-Macina: install deps and Playwright browsers")
|
||||||
parser.add_argument("--skip-deps", action="store_true", help="Skip installing Python dependencies from requirements.txt")
|
parser.add_argument("--skip-deps", action="store_true", help="Skip installing Python dependencies from requirements.txt")
|
||||||
parser.add_argument("--no-playwright", action="store_true", help="Skip running 'playwright install' (only install packages)")
|
parser.add_argument("--no-playwright", action="store_true", help="Skip running 'playwright install' (only install packages)")
|
||||||
parser.add_argument("--playwright-only", action="store_true", help="Only run 'playwright install' (skips dependency installation)")
|
parser.add_argument("--playwright-only", action="store_true", help="Only run 'playwright install' (skips dependency installation)")
|
||||||
|
parser.add_argument("--browsers", type=str, default="chromium", help="Comma-separated list of browsers to install: chromium,firefox,webkit or 'all' (default: chromium)")
|
||||||
|
parser.add_argument("--install-editable", action="store_true", help="Install the project in editable mode (pip install -e .) for running tests")
|
||||||
|
deno_group = parser.add_mutually_exclusive_group()
|
||||||
|
deno_group.add_argument("--install-deno", action="store_true", help="Install the Deno runtime (default behavior; kept for explicitness)")
|
||||||
|
deno_group.add_argument("--no-deno", action="store_true", help="Skip installing Deno runtime (opt out)")
|
||||||
|
parser.add_argument("--deno-version", type=str, default=None, help="Specific Deno version to install (e.g., v1.34.3)")
|
||||||
parser.add_argument("--upgrade-pip", action="store_true", help="Upgrade pip/setuptools/wheel before installing requirements")
|
parser.add_argument("--upgrade-pip", action="store_true", help="Upgrade pip/setuptools/wheel before installing requirements")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -60,7 +139,13 @@ def main() -> int:
|
|||||||
run([sys.executable, "-m", "pip", "install", "playwright"])
|
run([sys.executable, "-m", "pip", "install", "playwright"])
|
||||||
|
|
||||||
print("Installing Playwright browsers (this may download several hundred MB)...")
|
print("Installing Playwright browsers (this may download several hundred MB)...")
|
||||||
run([sys.executable, "-m", "playwright", "install"])
|
try:
|
||||||
|
cmd = _build_playwright_install_cmd(args.browsers)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
run(cmd)
|
||||||
print("Playwright browsers installed successfully.")
|
print("Playwright browsers installed successfully.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -82,7 +167,32 @@ def main() -> int:
|
|||||||
run([sys.executable, "-m", "pip", "install", "playwright"])
|
run([sys.executable, "-m", "pip", "install", "playwright"])
|
||||||
|
|
||||||
print("Installing Playwright browsers (this may download several hundred MB)...")
|
print("Installing Playwright browsers (this may download several hundred MB)...")
|
||||||
run([sys.executable, "-m", "playwright", "install"])
|
try:
|
||||||
|
cmd = _build_playwright_install_cmd(args.browsers)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"Error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
run(cmd)
|
||||||
|
|
||||||
|
# Optional: install the project in editable mode so tests can import the package
|
||||||
|
if args.install_editable:
|
||||||
|
print("Installing project in editable mode (pip install -e .) ...")
|
||||||
|
run([sys.executable, "-m", "pip", "install", "-e", "."])
|
||||||
|
|
||||||
|
# Optional: install Deno runtime (default: install unless --no-deno is passed)
|
||||||
|
install_deno_requested = True
|
||||||
|
if getattr(args, "no_deno", False):
|
||||||
|
install_deno_requested = False
|
||||||
|
elif getattr(args, "install_deno", False):
|
||||||
|
install_deno_requested = True
|
||||||
|
|
||||||
|
if install_deno_requested:
|
||||||
|
print("Installing Deno runtime...")
|
||||||
|
rc = _install_deno(args.deno_version)
|
||||||
|
if rc != 0:
|
||||||
|
print("Deno installation failed.", file=sys.stderr)
|
||||||
|
return rc
|
||||||
|
|
||||||
print("Setup complete.")
|
print("Setup complete.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user