diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index d2d8920..7d3dfda 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -463,6 +463,193 @@ local function ensure_mpv_ipc_server() return (now and now ~= '') and true or false end +local function ensure_pipeline_helper_running() + -- IMPORTANT: do NOT spawn Python from inside mpv. + -- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows + -- no-console flags; spawning here can flash a console window. + return _is_pipeline_helper_ready() and true or false +end + +local _ipc_async_busy = false +local _ipc_async_queue = {} + +local function _run_helper_request_async(req, timeout_seconds, cb) + cb = cb or function() end + + if _ipc_async_busy then + _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb } + return + end + _ipc_async_busy = true + + local function done(resp, err) + _ipc_async_busy = false + cb(resp, err) + + if #_ipc_async_queue > 0 then + local next_job = table.remove(_ipc_async_queue, 1) + -- Schedule next job slightly later to let mpv deliver any pending events. + mp.add_timeout(0.01, function() + _run_helper_request_async(next_job.req, next_job.timeout, next_job.cb) + end) + end + end + + if type(req) ~= 'table' then + done(nil, 'invalid request') + return + end + + ensure_mpv_ipc_server() + if not ensure_pipeline_helper_running() then + done(nil, 'helper not running') + return + end + + -- Assign id. + local id = tostring(req.id or '') + if id == '' then + id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) + req.id = id + end + + local label = '' + if req.op then + label = 'op=' .. tostring(req.op) + elseif req.pipeline then + label = 'cmd=' .. tostring(req.pipeline) + else + label = '(unknown)' + end + + -- Wait for helper READY without blocking the UI. + local ready_deadline = mp.get_time() + 3.0 + local ready_timer + ready_timer = mp.add_periodic_timer(0.05, function() + if _is_pipeline_helper_ready() then + ready_timer:kill() + + _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label) + local req_json = utils.format_json(req) + _last_ipc_last_req_json = req_json + + mp.set_property(PIPELINE_RESP_PROP, '') + mp.set_property(PIPELINE_REQ_PROP, req_json) + + local deadline = mp.get_time() + (timeout_seconds or 5) + local poll_timer + poll_timer = mp.add_periodic_timer(0.05, function() + if mp.get_time() >= deadline then + poll_timer:kill() + done(nil, 'timeout waiting response (' .. label .. ')') + return + end + + local resp_json = mp.get_property(PIPELINE_RESP_PROP) + if resp_json and resp_json ~= '' then + _last_ipc_last_resp_json = resp_json + local ok, resp = pcall(utils.parse_json, resp_json) + if ok and resp and resp.id == id then + poll_timer:kill() + _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) + done(resp, nil) + end + end + end) + + return + end + + if mp.get_time() >= ready_deadline then + ready_timer:kill() + done(nil, 'helper not ready') + return + end + end) +end + +local function _run_helper_request_response(req, timeout_seconds) + _last_ipc_error = '' + if not ensure_pipeline_helper_running() then + local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '') + _lua_log('ipc: helper not ready (ready=' .. rv .. '); attempting request anyway') + _last_ipc_error = 'helper not ready' + end + + do + -- Best-effort wait for heartbeat, but do not hard-fail the request. + local deadline = mp.get_time() + 1.5 + while mp.get_time() < deadline do + if _is_pipeline_helper_ready() then + break + end + mp.wait_event(0.05) + end + if not _is_pipeline_helper_ready() then + local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '') + _lua_log('ipc: proceeding without helper heartbeat; ready=' .. rv) + _last_ipc_error = 'helper heartbeat missing (ready=' .. rv .. ')' + end + end + + if type(req) ~= 'table' then + return nil + end + + local id = tostring(req.id or '') + if id == '' then + id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) + req.id = id + end + + local label = '' + if req.op then + label = 'op=' .. tostring(req.op) + elseif req.pipeline then + label = 'cmd=' .. tostring(req.pipeline) + else + label = '(unknown)' + end + _lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label) + + local req_json = utils.format_json(req) + _last_ipc_last_req_json = req_json + mp.set_property(PIPELINE_RESP_PROP, '') + mp.set_property(PIPELINE_REQ_PROP, req_json) + -- Read-back for debugging: confirms MPV accepted the property write. + local echoed = mp.get_property(PIPELINE_REQ_PROP) or '' + if echoed == '' then + _lua_log('ipc: WARNING request property echoed empty after set') + end + + 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 + _last_ipc_last_resp_json = resp_json + local ok, resp = pcall(utils.parse_json, resp_json) + if ok and resp and resp.id == id then + _lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) + return resp + end + end + mp.wait_event(0.05) + end + + _lua_log('ipc: timeout waiting response; ' .. label) + _last_ipc_error = 'timeout waiting response (' .. label .. ')' + return nil +end + +-- IPC helper: return the whole response object (stdout/stderr/error/table) +local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_seconds) + local req = { pipeline = pipeline_cmd } + if seeds then + req.seeds = seeds + end + return _run_helper_request_response(req, timeout_seconds) +end + local function quote_pipeline_arg(s) -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing. s = tostring(s or '') @@ -758,11 +945,25 @@ local function _capture_screenshot() local temp_dir = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp' local out_path = utils.join_path(temp_dir, filename) - local ok = pcall(function() - mp.commandv('screenshot-to-file', out_path, 'video') - end) + local function do_screenshot(mode) + mode = mode or 'video' + local ok, err = pcall(function() + return mp.commandv('screenshot-to-file', out_path, mode) + end) + return ok, err + end + + -- Try 'video' first (no OSD). If that fails (e.g. audio mode without video/art), + -- try 'window' as fallback. + local ok = do_screenshot('video') if not ok then - mp.osd_message('Screenshot failed', 2) + _lua_log('screenshot: video-mode failed; trying window-mode') + ok = do_screenshot('window') + end + + if not ok then + _lua_log('screenshot: BOTH video and window modes FAILED') + mp.osd_message('Screenshot failed (no frames)', 2) return end @@ -775,30 +976,20 @@ local function _capture_screenshot() mp.osd_message('Select a store first (Store button)', 2) return end - - local python_exe = _resolve_python_exe(true) - if not python_exe or python_exe == '' then - mp.osd_message('Screenshot saved; Python not found', 3) - return - end - - local start_dir = mp.get_script_directory() or '' - local cli_py = find_file_upwards(start_dir, 'CLI.py', 8) - if not cli_py or cli_py == '' or not utils.file_info(cli_py) then - mp.osd_message('Screenshot saved; CLI.py not found', 3) - return - end - - local res = utils.subprocess({ - args = { python_exe, cli_py, 'add-file', '-store', selected_store, '-path', out_path }, - cancellable = false, - }) - - if res and res.status == 0 then + + mp.osd_message('Saving screenshot...', 1) + + -- optimization: use persistent pipeline helper instead of spawning new python process + -- escape paths for command line + local cmd = 'add-file -store "' .. selected_store .. '" -path "' .. out_path .. '"' + + local resp = run_pipeline_via_ipc_response(cmd, nil, 10) + + if resp and resp.success then mp.osd_message('Screenshot saved to store: ' .. selected_store, 3) else - local stderr = (res and res.stderr) or 'unknown error' - mp.osd_message('Screenshot upload failed: ' .. tostring(stderr), 5) + local err = (resp and resp.error) or (resp and resp.stderr) or 'IPC error' + mp.osd_message('Screenshot upload failed: ' .. tostring(err), 5) end end @@ -1054,91 +1245,6 @@ local function _pick_folder_windows() return nil end --- Forward declaration: used by run_pipeline_via_ipc_response before definition. -local ensure_pipeline_helper_running - -local function _run_helper_request_response(req, timeout_seconds) - _last_ipc_error = '' - if not ensure_pipeline_helper_running() then - local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '') - _lua_log('ipc: helper not ready (ready=' .. rv .. '); attempting request anyway') - _last_ipc_error = 'helper not ready' - end - - do - -- Best-effort wait for heartbeat, but do not hard-fail the request. - local deadline = mp.get_time() + 1.5 - while mp.get_time() < deadline do - if _is_pipeline_helper_ready() then - break - end - mp.wait_event(0.05) - end - if not _is_pipeline_helper_ready() then - local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '') - _lua_log('ipc: proceeding without helper heartbeat; ready=' .. rv) - _last_ipc_error = 'helper heartbeat missing (ready=' .. rv .. ')' - end - end - - if type(req) ~= 'table' then - return nil - end - - local id = tostring(req.id or '') - if id == '' then - id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) - req.id = id - end - - local label = '' - if req.op then - label = 'op=' .. tostring(req.op) - elseif req.pipeline then - label = 'cmd=' .. tostring(req.pipeline) - else - label = '(unknown)' - end - _lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label) - - local req_json = utils.format_json(req) - _last_ipc_last_req_json = req_json - mp.set_property(PIPELINE_RESP_PROP, '') - mp.set_property(PIPELINE_REQ_PROP, req_json) - -- Read-back for debugging: confirms MPV accepted the property write. - local echoed = mp.get_property(PIPELINE_REQ_PROP) or '' - if echoed == '' then - _lua_log('ipc: WARNING request property echoed empty after set') - end - - 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 - _last_ipc_last_resp_json = resp_json - local ok, resp = pcall(utils.parse_json, resp_json) - if ok and resp and resp.id == id then - _lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) - return resp - end - end - mp.wait_event(0.05) - end - - _lua_log('ipc: timeout waiting response; ' .. label) - _last_ipc_error = 'timeout waiting response (' .. label .. ')' - return nil -end - --- IPC helper: return the whole response object (stdout/stderr/error/table) -local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_seconds) - local req = { pipeline = pipeline_cmd } - if seeds then - req.seeds = seeds - end - return _run_helper_request_response(req, timeout_seconds) -end - local function _store_names_key(names) if type(names) ~= 'table' or #names == 0 then return '' @@ -1438,101 +1544,6 @@ local function _get_cached_formats_table(url) return nil end -local function _run_helper_request_async(req, timeout_seconds, cb) - cb = cb or function() end - - if _ipc_async_busy then - _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb } - return - end - _ipc_async_busy = true - - local function done(resp, err) - _ipc_async_busy = false - cb(resp, err) - - if #_ipc_async_queue > 0 then - local next_job = table.remove(_ipc_async_queue, 1) - -- Schedule next job slightly later to let mpv deliver any pending events. - mp.add_timeout(0.01, function() - _run_helper_request_async(next_job.req, next_job.timeout, next_job.cb) - end) - end - end - - if type(req) ~= 'table' then - done(nil, 'invalid request') - return - end - - ensure_mpv_ipc_server() - if not ensure_pipeline_helper_running() then - done(nil, 'helper not running') - return - end - - -- Assign id. - local id = tostring(req.id or '') - if id == '' then - id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) - req.id = id - end - - local label = '' - if req.op then - label = 'op=' .. tostring(req.op) - elseif req.pipeline then - label = 'cmd=' .. tostring(req.pipeline) - else - label = '(unknown)' - end - - -- Wait for helper READY without blocking the UI. - local ready_deadline = mp.get_time() + 3.0 - local ready_timer - ready_timer = mp.add_periodic_timer(0.05, function() - if _is_pipeline_helper_ready() then - ready_timer:kill() - - _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label) - local req_json = utils.format_json(req) - _last_ipc_last_req_json = req_json - - mp.set_property(PIPELINE_RESP_PROP, '') - mp.set_property(PIPELINE_REQ_PROP, req_json) - - local deadline = mp.get_time() + (timeout_seconds or 5) - local poll_timer - poll_timer = mp.add_periodic_timer(0.05, function() - if mp.get_time() >= deadline then - poll_timer:kill() - done(nil, 'timeout waiting response (' .. label .. ')') - return - end - - local resp_json = mp.get_property(PIPELINE_RESP_PROP) - if resp_json and resp_json ~= '' then - _last_ipc_last_resp_json = resp_json - local ok, resp = pcall(utils.parse_json, resp_json) - if ok and resp and resp.id == id then - poll_timer:kill() - _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) - done(resp, nil) - end - end - end) - - return - end - - if mp.get_time() >= ready_deadline then - ready_timer:kill() - done(nil, 'helper not ready') - return - end - end) -end - function FileState:fetch_formats(cb) local url = tostring(self.url or '') if url == '' or not _is_http_url(url) then @@ -2077,13 +2088,6 @@ mp.register_script_message('medios-download-pick-path', function() _pending_download = nil end) -ensure_pipeline_helper_running = function() - -- IMPORTANT: do NOT spawn Python from inside mpv. - -- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows - -- no-console flags; spawning here can flash a console window. - return _is_pipeline_helper_ready() and true or false -end - local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds) if not ensure_pipeline_helper_running() then return nil @@ -2177,72 +2181,8 @@ if not opts.cli_path then opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py" end --- Clean API wrapper for executing Python functions from Lua -local function _call_mpv_api(request) - -- Call the MPV Lua API (mpv_lua_api.py) with a JSON request. - -- Returns: JSON-decoded response object with {success, stdout, stderr, error, ...} - local request_json = utils.format_json(request) - - -- Try to get log file path; skip if not available - local log_file = '' - local home = os.getenv('USERPROFILE') or os.getenv('HOME') or '' - if home ~= '' then - log_file = home .. '/../../medios/Medios-Macina/Log/medeia-mpv-helper.log' - end - - _lua_log('api: calling mpv_lua_api cmd=' .. tostring(request.cmd)) - - local python_exe = _resolve_python_exe(true) - if not python_exe or python_exe == '' then - _lua_log('api: FAILED - no python exe') - return { success = false, error = 'could not find Python' } - end - - -- Try to locate API script - local api_script = nil - local script_dir = mp.get_script_directory() - if script_dir and script_dir ~= '' then - api_script = script_dir .. '/mpv_lua_api.py' - if not utils.file_info(api_script) then - api_script = script_dir .. '/../mpv_lua_api.py' - end - end - - if not api_script or api_script == '' or not utils.file_info(api_script) then - -- Fallback: try absolute path - local repo_root = os.getenv('USERPROFILE') - if repo_root then - api_script = repo_root .. '/../../../medios/Medios-Macina/MPV/mpv_lua_api.py' - end - end - - if not api_script or api_script == '' then - _lua_log('api: FAILED - could not locate mpv_lua_api.py') - return { success = false, error = 'could not locate mpv_lua_api.py' } - end - - _lua_log('api: python=' .. tostring(python_exe) .. ' script=' .. tostring(api_script)) - - local res = utils.subprocess({ - args = { python_exe, api_script, request_json, log_file }, - cancellable = false, - }) - - if res and res.status == 0 and res.stdout then - local ok, response = pcall(utils.parse_json, res.stdout) - if ok and response then - _lua_log('api: response success=' .. tostring(response.success)) - return response - else - _lua_log('api: failed to parse response: ' .. tostring(res.stdout)) - return { success = false, error = 'malformed response', stdout = res.stdout } - end - else - local stderr = res and res.stderr or 'unknown error' - _lua_log('api: subprocess failed status=' .. tostring(res and res.status or 'nil') .. ' stderr=' .. stderr) - return { success = false, error = stderr } - end -end +-- Ref: mpv_lua_api.py was removed in favor of pipeline_helper (run_pipeline_via_ipc_response). +-- This placeholder comment ensures we don't have code shifting issues. -- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). -- Calls the callback with stdout on success or error message on failure. @@ -2551,13 +2491,14 @@ local function _start_trim_with_range(range) end _lua_log('trim: final upload_cmd=' .. pipeline_cmd) - _lua_log('trim: === CALLING API FOR UPLOAD ===') + _lua_log('trim: === CALLING PIPELINE HELPER FOR UPLOAD ===') - -- Call the API to handle metadata/storage - local response = _call_mpv_api({ - cmd = 'execute_pipeline', - pipeline = pipeline_cmd, - }) + -- Optimization: use persistent pipeline helper + local response = run_pipeline_via_ipc_response(pipeline_cmd, nil, 60) + + if not response then + response = { success = false, error = "Timeout or IPC error" } + end _lua_log('trim: api response success=' .. tostring(response.success)) _lua_log('trim: api response error=' .. tostring(response.error or 'nil')) diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index fa0fad8..7ada01d 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -262,13 +262,14 @@ class MPV: def send(self, command: Dict[str, Any] | List[Any], - silent: bool = False) -> Optional[Dict[str, + silent: bool = False, + wait: bool = True) -> Optional[Dict[str, Any]]: client = self.client(silent=bool(silent)) try: if not client.connect(): return None - return client.send_command(command) + return client.send_command(command, wait=wait) except Exception as exc: if not silent: debug(f"MPV IPC error: {exc}") @@ -627,7 +628,8 @@ class MPV: # Ensure uosc.conf is available at the location uosc expects. try: - src_uosc_conf = repo_root / "MPV" / "LUA" / "uosc" / "uosc.conf" + # Source uosc.conf is located within the bundled scripts. + src_uosc_conf = repo_root / "MPV" / "portable_config" / "scripts" / "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. @@ -639,16 +641,8 @@ class MPV: cmd: List[str] = [ "mpv", f"--config-dir={str(portable_config_dir)}", - # Allow mpv to auto-load scripts from /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", @@ -656,14 +650,21 @@ class MPV: ] # 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. + # If --load-scripts=yes is set (standard), mpv will already pick up the loader shim + # at scripts/uosc.lua. We only add a manual --script fallback if that file is missing. 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)}") + # Check different possible source locations for uosc core. + uosc_paths = [ + portable_config_dir / "scripts" / "uosc" / "scripts" / "uosc" / "main.lua", + lua_dir / "uosc" / "scripts" / "uosc" / "main.lua" + ] + for p in uosc_paths: + if p.exists(): + cmd.append(f"--script={str(p)}") + break except Exception: pass @@ -1002,12 +1003,14 @@ class MPVIPCClient: def send_command(self, command_data: Dict[str, - Any] | List[Any]) -> Optional[Dict[str, + Any] | List[Any], + wait: bool = True) -> Optional[Dict[str, Any]]: """Send a command to mpv and get response. Args: command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...]) + wait: If True, wait for the command response. Returns: Response dict with 'error' key (value 'success' on success), or None on error. @@ -1030,6 +1033,7 @@ class MPVIPCClient: if "request_id" not in request: request["request_id"] = int(_time.time() * 1000) % 100000 + rid = request["request_id"] payload = json.dumps(request) + "\n" # Debug: log the command being sent @@ -1040,6 +1044,9 @@ class MPVIPCClient: # Send command self._write_payload(payload) + if not wait: + return {"error": "success", "request_id": rid, "async": True} + # 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. diff --git a/MPV/mpv_lua_api.py b/MPV/mpv_lua_api.py deleted file mode 100644 index 9a501a9..0000000 --- a/MPV/mpv_lua_api.py +++ /dev/null @@ -1,184 +0,0 @@ -"""MPV Lua API - Clean interface for Lua scripts to call Python functions. - -This module provides a streamlined way for mpv Lua scripts to execute Python -functions and commands without relying on the broken observe_property IPC pattern. - -Instead, Lua calls Python CLI directly via subprocess, and Python returns JSON -responses that Lua can parse. -""" - -import json -import logging -import sys -from pathlib import Path -from typing import Any, Dict, Optional - -# Add parent directory to path so we can import CLI, pipeline, cmdlet_catalog from root -_SCRIPT_DIR = Path(__file__).parent -_ROOT_DIR = _SCRIPT_DIR.parent -if str(_ROOT_DIR) not in sys.path: - sys.path.insert(0, str(_ROOT_DIR)) - - -def setup_logging(log_file: Optional[Path] = None) -> logging.Logger: - """Setup logging for MPV API calls.""" - logger = logging.getLogger("mpv-lua-api") - logger.setLevel(logging.DEBUG) - - if not logger.handlers: - if log_file: - handler = logging.FileHandler(str(log_file), encoding="utf-8") - else: - handler = logging.StreamHandler(sys.stderr) - - formatter = logging.Formatter( - "[%(asctime)s][%(levelname)s] %(message)s", - datefmt="%Y-%m-%d %H:%M:%S" - ) - handler.setFormatter(formatter) - logger.addHandler(handler) - - return logger - - -def log_to_helper(msg: str, log_file: Optional[Path] = None) -> None: - """Log a message that will appear in the helper log.""" - if log_file: - with open(log_file, "a", encoding="utf-8") as f: - f.write(f"[lua] {msg}\n") - - -def execute_pipeline( - pipeline_cmd: str, - log_file: Optional[Path] = None, - dry_run: bool = False, -) -> Dict[str, - Any]: - """Execute a pipeline command and return result as JSON. - - Args: - pipeline_cmd: Pipeline command string (e.g. "trim-file -path ... | add-file -store ...") - log_file: Optional path to helper log file for logging - dry_run: If True, log but don't execute - - Returns: - JSON object with keys: success, stdout, stderr, error, returncode - """ - try: - if log_file: - log_to_helper(f"[api] execute_pipeline cmd={pipeline_cmd}", log_file) - - if dry_run: - return { - "success": True, - "stdout": "", - "stderr": "DRY RUN - command not executed", - "error": None, - "returncode": 0, - "cmd": pipeline_cmd, - } - - # Call the CLI directly as subprocess - import subprocess - import shlex - - # Parse the pipeline command into separate arguments - cmd_args = shlex.split(pipeline_cmd) - - result = subprocess.run( - [sys.executable, - "-m", - "CLI"] + cmd_args, - capture_output=True, - text=True, - cwd=str(_ROOT_DIR), - env={ - **dict(__import__("os").environ), - "MEDEIA_MPV_CALLER": "lua" - }, - ) - - if log_file: - log_to_helper( - f"[api] result returncode={result.returncode} len_stdout={len(result.stdout or '')} len_stderr={len(result.stderr or '')}", - log_file, - ) - if result.stderr: - log_to_helper(f"[api] stderr: {result.stderr[:500]}", log_file) - - return { - "success": result.returncode == 0, - "stdout": result.stdout or "", - "stderr": result.stderr or "", - "error": None if result.returncode == 0 else result.stderr, - "returncode": result.returncode, - "cmd": pipeline_cmd, - } - - except Exception as exc: - msg = f"{type(exc).__name__}: {exc}" - if log_file: - log_to_helper(f"[api] exception {msg}", log_file) - - return { - "success": False, - "stdout": "", - "stderr": str(exc), - "error": msg, - "returncode": 1, - "cmd": pipeline_cmd, - } - - -def handle_api_request(request_json: str, log_file: Optional[Path] = None) -> str: - """Handle an API request from Lua and return JSON response. - - Request format: - { - "cmd": "execute_pipeline", - "pipeline": "trim-file -path ... | add-file -store ...", - ... - } - - Response format: JSON with result of the operation. - """ - try: - request = json.loads(request_json) - cmd = request.get("cmd") - - if cmd == "execute_pipeline": - pipeline_cmd = request.get("pipeline", "") - result = execute_pipeline(pipeline_cmd, log_file) - return json.dumps(result) - - else: - return json.dumps({ - "success": False, - "error": f"Unknown command: {cmd}", - }) - - except Exception as exc: - return json.dumps( - { - "success": False, - "error": f"{type(exc).__name__}: {exc}", - } - ) - - -if __name__ == "__main__": - # When called from Lua via subprocess: - # python mpv_lua_api.py - - if len(sys.argv) < 2: - print(json.dumps({ - "success": False, - "error": "No request provided" - })) - sys.exit(1) - - request_json = sys.argv[1] - log_file = Path(sys.argv[2]) if len(sys.argv) > 2 else None - - response = handle_api_request(request_json, log_file) - print(response) diff --git a/MPV/portable_config/mpv.conf b/MPV/portable_config/mpv.conf index 5c6e6e8..c1750d7 100644 --- a/MPV/portable_config/mpv.conf +++ b/MPV/portable_config/mpv.conf @@ -5,33 +5,27 @@ osd-bar=no # uosc will draw its own window controls and border if you disable window border border=no -# Keep the window size stable when loading files (don't resize to match aspect). # Ensure uosc texture/icon fonts are discoverable by libass. osd-fonts-dir=~~/scripts/uosc/fonts sub-fonts-dir=~~/scripts/uosc/ - ontop=yes autofit=100% - -save-position-on-quit=yes - -# Avoid showing embedded cover art for audio-only files. -audio-display=no -# Stretch the video to fill the window (ignore aspect ratio, may distort) +# Avoid showing embedded cover art for audio-only files if uosc isn't working, +# but we keep it enabled for now to ensure a window exists. +audio-display=yes keepaspect=no video-unscaled=no cursor-autohide=1000 -# gpu-next can be fragile on some Windows/D3D11 setups; prefer the stable VO. -vo=gpu +# modern gpu-next is preferred in recent mpv builds +vo=gpu-next,gpu,direct3d -# Show this after loading a new file. You can show text permanently instead by setting osd-msg1. +# Show this after loading a new file. osd-playing-msg=${!playlist-count==1:[${playlist-pos-1}/${playlist-count}] }${media-title} ${?width:${width}x${height}} ${?current-tracks/video/image==no:${?percent-pos==0:${duration}}${!percent-pos==0:${time-pos} / ${duration} (${percent-pos}%)}} osd-playing-msg-duration=7000 -# On most platforms you can make the background transparent and avoid black -# bars while still having all the screen space available for zooming in: +# Restore transparency options background=none background-color=0/0 @@ -64,7 +58,7 @@ input-commands=no-osd del user-data/mpv/image; disable-section image # disable i # Loop short videos like gifs. [loop-short] -profile-cond=duration < 30 and p['current-tracks/video/image'] == false +profile-cond=get('duration', 100) < 30 and p['current-tracks/video/image'] == false profile-restore=copy loop-file @@ -75,7 +69,7 @@ profile-restore=copy stop-screensaver=no [manga] -profile-cond=path:find('manga') +profile-cond=get('path', ''):find('manga') video-align-y=-1 # start from the top reset-on-next-file-remove=video-zoom # preserve the zoom when changing file reset-on-next-file-remove=panscan diff --git a/MPV/portable_config/scripts/uosc.lua b/MPV/portable_config/scripts/uosc.lua index ed4205d..41c3a9c 100644 --- a/MPV/portable_config/scripts/uosc.lua +++ b/MPV/portable_config/scripts/uosc.lua @@ -29,9 +29,17 @@ end if type(scripts_root) ~= 'string' then scripts_root = '' end + +local msg = require('mp.msg') +msg.info('[uosc-shim] loading uosc...') + -- Your current folder layout is: scripts/uosc/scripts/uosc/main.lua local uosc_dir = utils.join_path(scripts_root, 'uosc/scripts/uosc') +if not utils.file_info(utils.join_path(uosc_dir, 'main.lua')) then + msg.error('[uosc-shim] ERROR: main.lua not found at ' .. tostring(uosc_dir)) +end + -- uosc uses mp.get_script_directory() to find its adjacent resources (bin/, lib/, etc). -- Because this loader lives in scripts/, override it so uosc resolves paths correctly. local _orig_get_script_directory = mp.get_script_directory @@ -41,4 +49,11 @@ end add_package_path(uosc_dir) -dofile(utils.join_path(uosc_dir, 'main.lua')) +local ok, err = pcall(dofile, utils.join_path(uosc_dir, 'main.lua')) +if not ok then + msg.error('[uosc-shim] ERROR during dofile: ' .. tostring(err)) +else + msg.info('[uosc-shim] uosc loaded successfully') + -- Notify main.lua that we are alive + mp.commandv('script-message', 'uosc-version', 'bundled') +end diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua b/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua index 86f1973..b134c33 100644 --- a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua +++ b/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua @@ -145,20 +145,6 @@ function Controls:init_options() }) table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) if badge then self:register_badge_updater(badge, element) end - - -- Medeia integration: show the persisted store name in the tooltip. - -- Triggered by a matching command string and backed by a mpv user-data prop. - if type(params[2]) == 'string' and params[2]:find('medeia%-store%-picker', 1, true) then - local store_prop = 'user-data/medeia-selected-store' - local function update_store_tooltip() - local v = mp.get_property(store_prop) or '' - v = trim(tostring(v)) - element.tooltip = (v ~= '' and ('Store: ' .. v) or 'Store: (none)') - request_render() - end - element:observe_mp_property(store_prop, function() update_store_tooltip() end) - update_store_tooltip() - end end elseif kind == 'cycle' then if #params ~= 3 then diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index dc81473..1535ea0 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -190,11 +190,11 @@ def _ensure_lyric_overlay(mpv: MPV) -> None: pass -def _send_ipc_command(command: Dict[str, Any], silent: bool = False) -> Optional[Any]: +def _send_ipc_command(command: Dict[str, Any], silent: bool = False, wait: bool = True) -> Optional[Any]: """Send a command to the MPV IPC pipe and return the response.""" try: mpv = MPV() - return mpv.send(command, silent=silent) + return mpv.send(command, silent=silent, wait=wait) except Exception as e: if not silent: debug(f"IPC Error: {e}", file=sys.stderr) @@ -883,12 +883,14 @@ def _queue_items( Any]] = None, start_opts: Optional[Dict[str, Any]] = None, + wait: bool = True, ) -> bool: """Queue items to MPV, starting it if necessary. Args: items: List of items to queue clear_first: If True, the first item will replace the current playlist + wait: If True, wait for MPV to acknowledge the loadfile command. Returns: True if MPV was started, False if items were queued via IPC. @@ -1125,8 +1127,8 @@ def _queue_items( "request_id": 200 } try: - debug(f"Sending MPV {command_name}: {target_to_send} mode={mode}") - resp = _send_ipc_command(cmd, silent=True) + debug(f"Sending MPV {command_name}: {target_to_send} mode={mode} wait={wait}") + resp = _send_ipc_command(cmd, silent=True, wait=wait) debug(f"MPV {command_name} response: {resp}") except Exception as e: debug(f"Exception sending {command_name} to MPV: {e}", file=sys.stderr) @@ -1249,6 +1251,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: list_mode = parsed.get("list") play_mode = parsed.get("play") pause_mode = parsed.get("pause") + replace_mode = parsed.get("replace") save_mode = parsed.get("save") load_mode = parsed.get("load") current_mode = parsed.get("current") @@ -1259,7 +1262,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: only_log = bool( log_requested and not url_arg and index_arg is None and not clear_mode and not list_mode and not play_mode and not pause_mode and not save_mode - and not load_mode and not current_mode + and not load_mode and not current_mode and not replace_mode ) if only_log: return 0 @@ -1302,10 +1305,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: # Handle URL queuing mpv_started = False if url_arg: - mpv_started = _queue_items([url_arg], config=config, start_opts=start_opts) - # Auto-play the URL when it's queued via .pipe "url" (without explicit flags) - # unless other flags are present - if not (clear_mode or play_mode or pause_mode or save_mode or load_mode): + # If -replace is used, or if we have a URL and -play is requested, + # we prefer 'replace' mode which starts playback immediately and avoids IPC overhead. + # NOTE: Use wait=False for URLs because yt-dlp resolution can be slow and + # would cause the calling Lua script to timeout. + queue_replace = bool(replace_mode) + if play_mode and not replace_mode: + # If -play is used with a URL, treat it as "play this now". + # For better UX, we'll replace the current playlist. + queue_replace = True + + mpv_started = _queue_items([url_arg], clear_first=queue_replace, config=config, start_opts=start_opts, wait=False) + + if not (clear_mode or play_mode or pause_mode or save_mode or load_mode or replace_mode): if mpv_started: # MPV was just started, wait a moment for it to be ready, then play first item import time @@ -1314,30 +1326,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: index_arg = "1" # 1-based index for first item play_mode = True else: - # MPV was already running, get playlist and play the newly added item - playlist = _get_playlist(silent=True) - if playlist and len(playlist) > 0: - # Auto-play the last item in the playlist (the one we just added) - # Use 1-based indexing - index_arg = str(len(playlist)) - play_mode = True - else: - # Fallback: just list the playlist if we can't determine index - list_mode = True + # MPV was already running, just show the updated playlist. + list_mode = True - # If the user explicitly requested -play while queueing a URL, interpret that - # as "play the URL I just queued" (not merely "unpause whatever is currently playing"). - if play_mode and index_arg is None: - if mpv_started: - # MPV was just started; give it a moment, then play first item. - import time - - time.sleep(0.5) - index_arg = "1" - else: - playlist = _get_playlist(silent=True) - if playlist and len(playlist) > 0: - index_arg = str(len(playlist)) + # If we used queue_replace, the URL is already playing. Clear play/index args to avoid redundant commands. + if queue_replace: + play_mode = False + index_arg = None # Ensure lyric overlay is running (auto-discovery handled by MPV.lyric). try: @@ -2140,8 +2135,13 @@ CMDLET = Cmdlet( description="Remove the selected item, or clear entire playlist if no index provided", ), CmdletArg(name="list", type="flag", description="List items (default)"), - CmdletArg(name="play", type="flag", description="Resume playback"), + CmdletArg(name="play", type="flag", description="Resume playback or play specific index/URL"), CmdletArg(name="pause", type="flag", description="Pause playback"), + CmdletArg( + name="replace", + type="flag", + description="Replace current playlist when adding index or URL", + ), CmdletArg( name="save", type="flag", diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 2a7dc9c..186833f 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -92,11 +92,21 @@ class ProgressBar: if self.quiet: return + term_width = shutil.get_terminal_size((80, 20)).columns percent = int(100 * (self.current / self.total)) filled = int(self.bar_width * self.current // self.total) bar = "█" * filled + "░" * (self.bar_width - filled) - sys.stdout.write(f"\r [{bar}] {percent:3}% | {step_name.ljust(30)}") + # Overwrite previous bar/label by moving up if not the first step + if self.current > 1: + sys.stdout.write("\033[2A") + + bar_line = f"[{bar}]" + info_line = f"{percent}% | {step_name}" + + sys.stdout.write(f"\r{bar_line.center(term_width)}\n") + # Clear line and print info line centered + sys.stdout.write(f"\r\033[K{info_line.center(term_width)}\r") sys.stdout.flush() if self.current == self.total: @@ -685,8 +695,6 @@ def main() -> int: os.system("cls" if os.name == "nt" else "clear") # Center the logo logo_lines = LOGO.strip().splitlines() - # Filter out the ruler lines if they are still there - logo_lines = [line for line in logo_lines if not any(c.isdigit() for c in line.strip())] print("\n" * 2) for line in logo_lines: @@ -925,8 +933,6 @@ def main() -> int: os.system('cls' if os.name == 'nt' else 'clear') term_width = shutil.get_terminal_size((80, 20)).columns logo_lines = LOGO.strip().splitlines() - # Filter out the ruler lines - logo_lines = [line for line in logo_lines if not any(c.isdigit() for c in line.strip())] print("\n" * 2) for line in logo_lines: print(line.center(term_width))