This commit is contained in:
2026-01-12 17:55:04 -08:00
parent e45138568f
commit d0a68da1f5
8 changed files with 318 additions and 553 deletions

View File

@@ -463,6 +463,193 @@ local function ensure_mpv_ipc_server()
return (now and now ~= '') and true or false return (now and now ~= '') and true or false
end 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) local function quote_pipeline_arg(s)
-- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing. -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing.
s = tostring(s or '') 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 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 out_path = utils.join_path(temp_dir, filename)
local ok = pcall(function() local function do_screenshot(mode)
mp.commandv('screenshot-to-file', out_path, 'video') mode = mode or 'video'
end) 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 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 return
end end
@@ -776,29 +977,19 @@ local function _capture_screenshot()
return return
end end
local python_exe = _resolve_python_exe(true) mp.osd_message('Saving screenshot...', 1)
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 '' -- optimization: use persistent pipeline helper instead of spawning new python process
local cli_py = find_file_upwards(start_dir, 'CLI.py', 8) -- escape paths for command line
if not cli_py or cli_py == '' or not utils.file_info(cli_py) then local cmd = 'add-file -store "' .. selected_store .. '" -path "' .. out_path .. '"'
mp.osd_message('Screenshot saved; CLI.py not found', 3)
return
end
local res = utils.subprocess({ local resp = run_pipeline_via_ipc_response(cmd, nil, 10)
args = { python_exe, cli_py, 'add-file', '-store', selected_store, '-path', out_path },
cancellable = false,
})
if res and res.status == 0 then if resp and resp.success then
mp.osd_message('Screenshot saved to store: ' .. selected_store, 3) mp.osd_message('Screenshot saved to store: ' .. selected_store, 3)
else else
local stderr = (res and res.stderr) or 'unknown error' local err = (resp and resp.error) or (resp and resp.stderr) or 'IPC error'
mp.osd_message('Screenshot upload failed: ' .. tostring(stderr), 5) mp.osd_message('Screenshot upload failed: ' .. tostring(err), 5)
end end
end end
@@ -1054,91 +1245,6 @@ local function _pick_folder_windows()
return nil return nil
end 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) local function _store_names_key(names)
if type(names) ~= 'table' or #names == 0 then if type(names) ~= 'table' or #names == 0 then
return '' return ''
@@ -1438,101 +1544,6 @@ local function _get_cached_formats_table(url)
return nil return nil
end 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) function FileState:fetch_formats(cb)
local url = tostring(self.url or '') local url = tostring(self.url or '')
if url == '' or not _is_http_url(url) then 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 _pending_download = nil
end) 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) local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds)
if not ensure_pipeline_helper_running() then if not ensure_pipeline_helper_running() then
return nil 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" opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py"
end end
-- Clean API wrapper for executing Python functions from Lua -- Ref: mpv_lua_api.py was removed in favor of pipeline_helper (run_pipeline_via_ipc_response).
local function _call_mpv_api(request) -- This placeholder comment ensures we don't have code shifting issues.
-- 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
-- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). -- 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. -- Calls the callback with stdout on success or error message on failure.
@@ -2551,13 +2491,14 @@ local function _start_trim_with_range(range)
end end
_lua_log('trim: final upload_cmd=' .. pipeline_cmd) _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 -- Optimization: use persistent pipeline helper
local response = _call_mpv_api({ local response = run_pipeline_via_ipc_response(pipeline_cmd, nil, 60)
cmd = 'execute_pipeline',
pipeline = pipeline_cmd, 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 success=' .. tostring(response.success))
_lua_log('trim: api response error=' .. tostring(response.error or 'nil')) _lua_log('trim: api response error=' .. tostring(response.error or 'nil'))

View File

@@ -262,13 +262,14 @@ class MPV:
def send(self, def send(self,
command: Dict[str, command: Dict[str,
Any] | List[Any], Any] | List[Any],
silent: bool = False) -> Optional[Dict[str, silent: bool = False,
wait: bool = True) -> Optional[Dict[str,
Any]]: Any]]:
client = self.client(silent=bool(silent)) client = self.client(silent=bool(silent))
try: try:
if not client.connect(): if not client.connect():
return None return None
return client.send_command(command) return client.send_command(command, wait=wait)
except Exception as exc: except Exception as exc:
if not silent: if not silent:
debug(f"MPV IPC error: {exc}") debug(f"MPV IPC error: {exc}")
@@ -627,7 +628,8 @@ class MPV:
# Ensure uosc.conf is available at the location uosc expects. # Ensure uosc.conf is available at the location uosc expects.
try: 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" dst_uosc_conf = portable_config_dir / "script-opts" / "uosc.conf"
if src_uosc_conf.exists(): if src_uosc_conf.exists():
# Only seed a default config if the user doesn't already have one. # Only seed a default config if the user doesn't already have one.
@@ -639,16 +641,8 @@ class MPV:
cmd: List[str] = [ cmd: List[str] = [
"mpv", "mpv",
f"--config-dir={str(portable_config_dir)}", f"--config-dir={str(portable_config_dir)}",
# Allow mpv to auto-load scripts from <config-dir>/scripts/ (e.g., thumbfast).
"--load-scripts=yes", "--load-scripts=yes",
"--osc=no", "--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", "--ytdl=yes",
f"--input-ipc-server={self.ipc_path}", f"--input-ipc-server={self.ipc_path}",
"--idle=yes", "--idle=yes",
@@ -656,14 +650,21 @@ class MPV:
] ]
# uosc and other scripts are expected to be auto-loaded from portable_config/scripts. # 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: try:
uosc_entry = portable_config_dir / "scripts" / "uosc.lua" uosc_entry = portable_config_dir / "scripts" / "uosc.lua"
if not uosc_entry.exists() and self.lua_script_path: if not uosc_entry.exists() and self.lua_script_path:
lua_dir = Path(self.lua_script_path).resolve().parent lua_dir = Path(self.lua_script_path).resolve().parent
uosc_main = lua_dir / "uosc" / "scripts" / "uosc" / "main.lua" # Check different possible source locations for uosc core.
if uosc_main.exists(): uosc_paths = [
cmd.append(f"--script={str(uosc_main)}") 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: except Exception:
pass pass
@@ -1002,12 +1003,14 @@ class MPVIPCClient:
def send_command(self, def send_command(self,
command_data: Dict[str, command_data: Dict[str,
Any] | List[Any]) -> Optional[Dict[str, Any] | List[Any],
wait: bool = True) -> Optional[Dict[str,
Any]]: Any]]:
"""Send a command to mpv and get response. """Send a command to mpv and get response.
Args: Args:
command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...]) command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...])
wait: If True, wait for the command response.
Returns: Returns:
Response dict with 'error' key (value 'success' on success), or None on error. 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: if "request_id" not in request:
request["request_id"] = int(_time.time() * 1000) % 100000 request["request_id"] = int(_time.time() * 1000) % 100000
rid = request["request_id"]
payload = json.dumps(request) + "\n" payload = json.dumps(request) + "\n"
# Debug: log the command being sent # Debug: log the command being sent
@@ -1040,6 +1044,9 @@ class MPVIPCClient:
# Send command # Send command
self._write_payload(payload) self._write_payload(payload)
if not wait:
return {"error": "success", "request_id": rid, "async": True}
# Receive response # Receive response
# We need to read lines until we find the one with matching request_id # We need to read lines until we find the one with matching request_id
# or until timeout/error. MPV might send events in between. # or until timeout/error. MPV might send events in between.

View File

@@ -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 <json-request>
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)

View File

@@ -5,33 +5,27 @@ osd-bar=no
# uosc will draw its own window controls and border if you disable window border # uosc will draw its own window controls and border if you disable window border
border=no 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. # Ensure uosc texture/icon fonts are discoverable by libass.
osd-fonts-dir=~~/scripts/uosc/fonts osd-fonts-dir=~~/scripts/uosc/fonts
sub-fonts-dir=~~/scripts/uosc/ sub-fonts-dir=~~/scripts/uosc/
ontop=yes ontop=yes
autofit=100% autofit=100%
# Avoid showing embedded cover art for audio-only files if uosc isn't working,
save-position-on-quit=yes # but we keep it enabled for now to ensure a window exists.
audio-display=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)
keepaspect=no keepaspect=no
video-unscaled=no video-unscaled=no
cursor-autohide=1000 cursor-autohide=1000
# gpu-next can be fragile on some Windows/D3D11 setups; prefer the stable VO. # modern gpu-next is preferred in recent mpv builds
vo=gpu 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=${!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 osd-playing-msg-duration=7000
# On most platforms you can make the background transparent and avoid black # Restore transparency options
# bars while still having all the screen space available for zooming in:
background=none background=none
background-color=0/0 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 videos like gifs.
[loop-short] [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 profile-restore=copy
loop-file loop-file
@@ -75,7 +69,7 @@ profile-restore=copy
stop-screensaver=no stop-screensaver=no
[manga] [manga]
profile-cond=path:find('manga') profile-cond=get('path', ''):find('manga')
video-align-y=-1 # start from the top 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=video-zoom # preserve the zoom when changing file
reset-on-next-file-remove=panscan reset-on-next-file-remove=panscan

View File

@@ -29,9 +29,17 @@ end
if type(scripts_root) ~= 'string' then if type(scripts_root) ~= 'string' then
scripts_root = '' scripts_root = ''
end end
local msg = require('mp.msg')
msg.info('[uosc-shim] loading uosc...')
-- Your current folder layout is: scripts/uosc/scripts/uosc/main.lua -- Your current folder layout is: scripts/uosc/scripts/uosc/main.lua
local uosc_dir = utils.join_path(scripts_root, 'uosc/scripts/uosc') 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). -- 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. -- Because this loader lives in scripts/, override it so uosc resolves paths correctly.
local _orig_get_script_directory = mp.get_script_directory local _orig_get_script_directory = mp.get_script_directory
@@ -41,4 +49,11 @@ end
add_package_path(uosc_dir) 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

View File

@@ -145,20 +145,6 @@ function Controls:init_options()
}) })
table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1}) table_assign(control, {element = element, sizing = 'static', scale = 1, ratio = 1})
if badge then self:register_badge_updater(badge, element) end 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 end
elseif kind == 'cycle' then elseif kind == 'cycle' then
if #params ~= 3 then if #params ~= 3 then

View File

@@ -190,11 +190,11 @@ def _ensure_lyric_overlay(mpv: MPV) -> None:
pass 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.""" """Send a command to the MPV IPC pipe and return the response."""
try: try:
mpv = MPV() mpv = MPV()
return mpv.send(command, silent=silent) return mpv.send(command, silent=silent, wait=wait)
except Exception as e: except Exception as e:
if not silent: if not silent:
debug(f"IPC Error: {e}", file=sys.stderr) debug(f"IPC Error: {e}", file=sys.stderr)
@@ -883,12 +883,14 @@ def _queue_items(
Any]] = None, Any]] = None,
start_opts: Optional[Dict[str, start_opts: Optional[Dict[str,
Any]] = None, Any]] = None,
wait: bool = True,
) -> bool: ) -> bool:
"""Queue items to MPV, starting it if necessary. """Queue items to MPV, starting it if necessary.
Args: Args:
items: List of items to queue items: List of items to queue
clear_first: If True, the first item will replace the current playlist clear_first: If True, the first item will replace the current playlist
wait: If True, wait for MPV to acknowledge the loadfile command.
Returns: Returns:
True if MPV was started, False if items were queued via IPC. True if MPV was started, False if items were queued via IPC.
@@ -1125,8 +1127,8 @@ def _queue_items(
"request_id": 200 "request_id": 200
} }
try: try:
debug(f"Sending MPV {command_name}: {target_to_send} mode={mode}") debug(f"Sending MPV {command_name}: {target_to_send} mode={mode} wait={wait}")
resp = _send_ipc_command(cmd, silent=True) resp = _send_ipc_command(cmd, silent=True, wait=wait)
debug(f"MPV {command_name} response: {resp}") debug(f"MPV {command_name} response: {resp}")
except Exception as e: except Exception as e:
debug(f"Exception sending {command_name} to MPV: {e}", file=sys.stderr) 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") list_mode = parsed.get("list")
play_mode = parsed.get("play") play_mode = parsed.get("play")
pause_mode = parsed.get("pause") pause_mode = parsed.get("pause")
replace_mode = parsed.get("replace")
save_mode = parsed.get("save") save_mode = parsed.get("save")
load_mode = parsed.get("load") load_mode = parsed.get("load")
current_mode = parsed.get("current") current_mode = parsed.get("current")
@@ -1259,7 +1262,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
only_log = bool( only_log = bool(
log_requested and not url_arg and index_arg is None and not clear_mode 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 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: if only_log:
return 0 return 0
@@ -1302,10 +1305,19 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
# Handle URL queuing # Handle URL queuing
mpv_started = False mpv_started = False
if url_arg: if url_arg:
mpv_started = _queue_items([url_arg], config=config, start_opts=start_opts) # If -replace is used, or if we have a URL and -play is requested,
# Auto-play the URL when it's queued via .pipe "url" (without explicit flags) # we prefer 'replace' mode which starts playback immediately and avoids IPC overhead.
# unless other flags are present # NOTE: Use wait=False for URLs because yt-dlp resolution can be slow and
if not (clear_mode or play_mode or pause_mode or save_mode or load_mode): # 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: if mpv_started:
# MPV was just started, wait a moment for it to be ready, then play first item # MPV was just started, wait a moment for it to be ready, then play first item
import time 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 index_arg = "1" # 1-based index for first item
play_mode = True play_mode = True
else: else:
# MPV was already running, get playlist and play the newly added item # MPV was already running, just show the updated playlist.
playlist = _get_playlist(silent=True) list_mode = 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
# If the user explicitly requested -play while queueing a URL, interpret that # If we used queue_replace, the URL is already playing. Clear play/index args to avoid redundant commands.
# as "play the URL I just queued" (not merely "unpause whatever is currently playing"). if queue_replace:
if play_mode and index_arg is None: play_mode = False
if mpv_started: index_arg = None
# 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))
# Ensure lyric overlay is running (auto-discovery handled by MPV.lyric). # Ensure lyric overlay is running (auto-discovery handled by MPV.lyric).
try: try:
@@ -2140,8 +2135,13 @@ CMDLET = Cmdlet(
description="Remove the selected item, or clear entire playlist if no index provided", description="Remove the selected item, or clear entire playlist if no index provided",
), ),
CmdletArg(name="list", type="flag", description="List items (default)"), 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="pause", type="flag", description="Pause playback"),
CmdletArg(
name="replace",
type="flag",
description="Replace current playlist when adding index or URL",
),
CmdletArg( CmdletArg(
name="save", name="save",
type="flag", type="flag",

View File

@@ -92,11 +92,21 @@ class ProgressBar:
if self.quiet: if self.quiet:
return return
term_width = shutil.get_terminal_size((80, 20)).columns
percent = int(100 * (self.current / self.total)) percent = int(100 * (self.current / self.total))
filled = int(self.bar_width * self.current // self.total) filled = int(self.bar_width * self.current // self.total)
bar = "" * filled + "" * (self.bar_width - filled) 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() sys.stdout.flush()
if self.current == self.total: if self.current == self.total:
@@ -685,8 +695,6 @@ def main() -> int:
os.system("cls" if os.name == "nt" else "clear") os.system("cls" if os.name == "nt" else "clear")
# Center the logo # Center the logo
logo_lines = LOGO.strip().splitlines() 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) print("\n" * 2)
for line in logo_lines: for line in logo_lines:
@@ -925,8 +933,6 @@ def main() -> int:
os.system('cls' if os.name == 'nt' else 'clear') os.system('cls' if os.name == 'nt' else 'clear')
term_width = shutil.get_terminal_size((80, 20)).columns term_width = shutil.get_terminal_size((80, 20)).columns
logo_lines = LOGO.strip().splitlines() 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) print("\n" * 2)
for line in logo_lines: for line in logo_lines:
print(line.center(term_width)) print(line.center(term_width))