fd
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled
This commit is contained in:
569
MPV/LUA/main.lua
569
MPV/LUA/main.lua
@@ -6,6 +6,9 @@ local M = {}
|
||||
|
||||
local MEDEIA_LUA_VERSION = '2025-12-24'
|
||||
|
||||
-- Expose a tiny breadcrumb for debugging which script version is loaded.
|
||||
pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION)
|
||||
|
||||
-- Track whether uosc is available so menu calls don't fail with
|
||||
-- "Can't find script 'uosc' to send message to."
|
||||
local _uosc_loaded = false
|
||||
@@ -57,6 +60,24 @@ local function _lua_log(text)
|
||||
end
|
||||
local dir = ''
|
||||
|
||||
-- Prefer a stable repo-root Log/ folder based on the script directory.
|
||||
do
|
||||
local function _dirname(p)
|
||||
p = tostring(p or '')
|
||||
p = p:gsub('[/\\]+$', '')
|
||||
return p:match('(.*)[/\\]') or ''
|
||||
end
|
||||
|
||||
local base = mp.get_script_directory() or ''
|
||||
if base ~= '' then
|
||||
-- base is expected to be <repo>/MPV/LUA
|
||||
local root = _dirname(_dirname(base))
|
||||
if root ~= '' then
|
||||
dir = utils.join_path(root, 'Log')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Prefer repo-root Log/ for consistency with Python helper logs.
|
||||
do
|
||||
local function find_up(start_dir, relative_path, max_levels)
|
||||
@@ -122,6 +143,8 @@ local function _lua_log(text)
|
||||
end
|
||||
end
|
||||
|
||||
_lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))
|
||||
|
||||
local function ensure_uosc_loaded()
|
||||
if _uosc_loaded or _is_script_loaded('uosc') then
|
||||
_uosc_loaded = true
|
||||
@@ -274,13 +297,107 @@ end
|
||||
local _cached_store_names = {}
|
||||
local _store_cache_loaded = false
|
||||
|
||||
local SELECTED_STORE_PROP = 'user-data/medeia-selected-store'
|
||||
local STORE_PICKER_MENU_TYPE = 'medeia_store_picker'
|
||||
local _selected_store_loaded = false
|
||||
|
||||
local function _get_script_opts_dir()
|
||||
local dir = nil
|
||||
pcall(function()
|
||||
dir = mp.command_native({ 'expand-path', '~~/script-opts' })
|
||||
end)
|
||||
if type(dir) ~= 'string' or dir == '' then
|
||||
return nil
|
||||
end
|
||||
return dir
|
||||
end
|
||||
|
||||
local function _get_selected_store_conf_path()
|
||||
local dir = _get_script_opts_dir()
|
||||
if not dir then
|
||||
return nil
|
||||
end
|
||||
return utils.join_path(dir, 'medeia.conf')
|
||||
end
|
||||
|
||||
local function _load_selected_store_from_disk()
|
||||
local path = _get_selected_store_conf_path()
|
||||
if not path then
|
||||
return nil
|
||||
end
|
||||
local fh = io.open(path, 'r')
|
||||
if not fh then
|
||||
return nil
|
||||
end
|
||||
for line in fh:lines() do
|
||||
local s = trim(tostring(line or ''))
|
||||
if s ~= '' and s:sub(1, 1) ~= '#' and s:sub(1, 1) ~= ';' then
|
||||
local k, v = s:match('^([%w_%-]+)%s*=%s*(.*)$')
|
||||
if k and v and k:lower() == 'store' then
|
||||
fh:close()
|
||||
v = trim(tostring(v or ''))
|
||||
return v ~= '' and v or nil
|
||||
end
|
||||
end
|
||||
end
|
||||
fh:close()
|
||||
return nil
|
||||
end
|
||||
|
||||
local function _save_selected_store_to_disk(store)
|
||||
local path = _get_selected_store_conf_path()
|
||||
if not path then
|
||||
return false
|
||||
end
|
||||
local fh = io.open(path, 'w')
|
||||
if not fh then
|
||||
return false
|
||||
end
|
||||
fh:write('# Medeia MPV script options\n')
|
||||
fh:write('store=' .. tostring(store or '') .. '\n')
|
||||
fh:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function _get_selected_store()
|
||||
local v = ''
|
||||
pcall(function()
|
||||
v = tostring(mp.get_property(SELECTED_STORE_PROP) or '')
|
||||
end)
|
||||
return trim(tostring(v or ''))
|
||||
end
|
||||
|
||||
local function _set_selected_store(store)
|
||||
store = trim(tostring(store or ''))
|
||||
pcall(mp.set_property, SELECTED_STORE_PROP, store)
|
||||
pcall(_save_selected_store_to_disk, store)
|
||||
end
|
||||
|
||||
local function _ensure_selected_store_loaded()
|
||||
if _selected_store_loaded then
|
||||
return
|
||||
end
|
||||
_selected_store_loaded = true
|
||||
local disk = nil
|
||||
pcall(function()
|
||||
disk = _load_selected_store_from_disk()
|
||||
end)
|
||||
disk = trim(tostring(disk or ''))
|
||||
if disk ~= '' then
|
||||
pcall(mp.set_property, SELECTED_STORE_PROP, disk)
|
||||
end
|
||||
end
|
||||
|
||||
local _pipeline_helper_started = false
|
||||
local _last_ipc_error = ''
|
||||
local _last_ipc_last_req_json = ''
|
||||
local _last_ipc_last_resp_json = ''
|
||||
|
||||
local function _is_pipeline_helper_ready()
|
||||
local ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||
local ready = mp.get_property(PIPELINE_READY_PROP)
|
||||
if ready == nil or ready == '' then
|
||||
ready = mp.get_property_native(PIPELINE_READY_PROP)
|
||||
end
|
||||
if not ready then
|
||||
return false
|
||||
end
|
||||
@@ -303,6 +420,7 @@ local function _is_pipeline_helper_ready()
|
||||
return age <= 10
|
||||
end
|
||||
|
||||
-- If it's some other non-empty value, treat as ready.
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -833,13 +951,14 @@ local ensure_pipeline_helper_running
|
||||
local function _run_helper_request_response(req, timeout_seconds)
|
||||
_last_ipc_error = ''
|
||||
if not ensure_pipeline_helper_running() then
|
||||
_lua_log('ipc: helper not ready; cannot execute request')
|
||||
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'
|
||||
return nil
|
||||
end
|
||||
|
||||
do
|
||||
local deadline = mp.get_time() + 3.0
|
||||
-- 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
|
||||
@@ -847,10 +966,9 @@ local function _run_helper_request_response(req, timeout_seconds)
|
||||
mp.wait_event(0.05)
|
||||
end
|
||||
if not _is_pipeline_helper_ready() then
|
||||
local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP))
|
||||
_lua_log('ipc: helper not ready; ready=' .. rv)
|
||||
_last_ipc_error = 'helper not ready (ready=' .. rv .. ')'
|
||||
return nil
|
||||
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
|
||||
|
||||
@@ -914,9 +1032,67 @@ end
|
||||
|
||||
local function _refresh_store_cache(timeout_seconds)
|
||||
ensure_mpv_ipc_server()
|
||||
|
||||
-- First, try reading the pre-computed cached property (set by helper at startup).
|
||||
-- This avoids a request/response timeout if observe_property isn't working.
|
||||
local cached_json = mp.get_property('user-data/medeia-store-choices-cached')
|
||||
_lua_log('stores: cache_read cached_json=' .. tostring(cached_json) .. ' len=' .. tostring(cached_json and #cached_json or 0))
|
||||
|
||||
if cached_json and cached_json ~= '' then
|
||||
-- Try to parse as JSON (may fail if not valid JSON)
|
||||
local ok, cached_resp = pcall(utils.parse_json, cached_json)
|
||||
_lua_log('stores: cache_parse ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
|
||||
|
||||
-- Handle both cases: parsed object OR string (if JSON lib returns string)
|
||||
if ok then
|
||||
-- If parse returned a string, it might still be valid JSON; try parsing again
|
||||
if type(cached_resp) == 'string' then
|
||||
_lua_log('stores: cache_parse returned string, trying again...')
|
||||
ok, cached_resp = pcall(utils.parse_json, cached_resp)
|
||||
_lua_log('stores: cache_parse retry ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
|
||||
end
|
||||
|
||||
-- Now check if we have a table with choices
|
||||
if type(cached_resp) == 'table' and type(cached_resp.choices) == 'table' then
|
||||
local out = {}
|
||||
for _, v in ipairs(cached_resp.choices) do
|
||||
local name = trim(tostring(v or ''))
|
||||
if name ~= '' then
|
||||
out[#out + 1] = name
|
||||
end
|
||||
end
|
||||
_cached_store_names = out
|
||||
_store_cache_loaded = true
|
||||
local preview = ''
|
||||
if #_cached_store_names > 0 then
|
||||
preview = table.concat(_cached_store_names, ', ')
|
||||
end
|
||||
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores from cache: ' .. tostring(preview))
|
||||
return true
|
||||
else
|
||||
_lua_log('stores: cache_parse final type mismatch resp_type=' .. tostring(type(cached_resp)) .. ' choices_type=' .. tostring(cached_resp and type(cached_resp.choices) or 'n/a'))
|
||||
end
|
||||
else
|
||||
_lua_log('stores: cache_parse failed ok=' .. tostring(ok) .. ' resp=' .. tostring(cached_resp))
|
||||
end
|
||||
else
|
||||
_lua_log('stores: cache_empty cached_json=' .. tostring(cached_json))
|
||||
end
|
||||
|
||||
-- Fallback: request fresh store-choices from helper (with timeout).
|
||||
_lua_log('stores: requesting store-choices via helper (fallback)')
|
||||
local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1)
|
||||
if not resp or not resp.success or type(resp.choices) ~= 'table' then
|
||||
_lua_log('stores: failed to load store choices via helper; stderr=' .. tostring(resp and resp.stderr or '') .. ' error=' .. tostring(resp and resp.error or ''))
|
||||
_lua_log(
|
||||
'stores: failed to load store choices via helper; success='
|
||||
.. tostring(resp and resp.success or false)
|
||||
.. ' choices_type='
|
||||
.. tostring(resp and type(resp.choices) or 'nil')
|
||||
.. ' stderr='
|
||||
.. tostring(resp and resp.stderr or '')
|
||||
.. ' error='
|
||||
.. tostring(resp and resp.error or '')
|
||||
)
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -929,7 +1105,11 @@ local function _refresh_store_cache(timeout_seconds)
|
||||
end
|
||||
_cached_store_names = out
|
||||
_store_cache_loaded = true
|
||||
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via helper')
|
||||
local preview = ''
|
||||
if #_cached_store_names > 0 then
|
||||
preview = table.concat(_cached_store_names, ', ')
|
||||
end
|
||||
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via helper request: ' .. tostring(preview))
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -946,6 +1126,116 @@ local function _uosc_open_list_picker(menu_type, title, items)
|
||||
end
|
||||
end
|
||||
|
||||
local function _open_store_picker()
|
||||
_ensure_selected_store_loaded()
|
||||
|
||||
local selected = _get_selected_store()
|
||||
local cached_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
||||
local cached_preview = ''
|
||||
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
||||
cached_preview = table.concat(_cached_store_names, ', ')
|
||||
end
|
||||
_lua_log(
|
||||
'stores: open picker selected='
|
||||
.. tostring(selected)
|
||||
.. ' cached_count='
|
||||
.. tostring(cached_count)
|
||||
.. ' cached='
|
||||
.. tostring(cached_preview)
|
||||
)
|
||||
|
||||
local function build_items()
|
||||
local selected = _get_selected_store()
|
||||
local items = {}
|
||||
|
||||
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
||||
for _, name in ipairs(_cached_store_names) do
|
||||
name = trim(tostring(name or ''))
|
||||
if name ~= '' then
|
||||
local payload = { store = name }
|
||||
items[#items + 1] = {
|
||||
title = name,
|
||||
active = (selected ~= '' and name == selected) and true or false,
|
||||
value = { 'script-message-to', mp.get_script_name(), 'medeia-store-select', utils.format_json(payload) },
|
||||
}
|
||||
end
|
||||
end
|
||||
else
|
||||
items[#items + 1] = {
|
||||
title = 'No stores found',
|
||||
hint = 'Configure stores in config.conf',
|
||||
selectable = false,
|
||||
}
|
||||
end
|
||||
|
||||
return items
|
||||
end
|
||||
|
||||
-- Open immediately with whatever cache we have.
|
||||
_uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())
|
||||
|
||||
-- Best-effort refresh; retry briefly to avoid races where the helper isn't
|
||||
-- ready/observing yet at the exact moment the menu opens.
|
||||
local function attempt_refresh(tries_left)
|
||||
local before_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
||||
local before_preview = ''
|
||||
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
||||
before_preview = table.concat(_cached_store_names, ', ')
|
||||
end
|
||||
|
||||
local ok = _refresh_store_cache(1.2)
|
||||
local after_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
||||
local after_preview = ''
|
||||
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
||||
after_preview = table.concat(_cached_store_names, ', ')
|
||||
end
|
||||
|
||||
_lua_log(
|
||||
'stores: refresh attempt ok='
|
||||
.. tostring(ok)
|
||||
.. ' before='
|
||||
.. tostring(before_count)
|
||||
.. ' after='
|
||||
.. tostring(after_count)
|
||||
.. ' after='
|
||||
.. tostring(after_preview)
|
||||
)
|
||||
|
||||
if after_count > 0 and (after_count ~= before_count or after_preview ~= before_preview) then
|
||||
_lua_log('stores: reopening menu (store list changed)')
|
||||
_uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())
|
||||
return
|
||||
end
|
||||
|
||||
if tries_left > 0 then
|
||||
mp.add_timeout(0.25, function()
|
||||
attempt_refresh(tries_left - 1)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
mp.add_timeout(0.05, function()
|
||||
attempt_refresh(6)
|
||||
end)
|
||||
end
|
||||
|
||||
mp.register_script_message('medeia-store-picker', function()
|
||||
_open_store_picker()
|
||||
end)
|
||||
|
||||
mp.register_script_message('medeia-store-select', function(json)
|
||||
local ok, ev = pcall(utils.parse_json, json)
|
||||
if not ok or type(ev) ~= 'table' then
|
||||
return
|
||||
end
|
||||
local store = trim(tostring(ev.store or ''))
|
||||
if store == '' then
|
||||
return
|
||||
end
|
||||
_set_selected_store(store)
|
||||
mp.osd_message('Store: ' .. store, 2)
|
||||
end)
|
||||
|
||||
-- No-op handler for placeholder menu items.
|
||||
mp.register_script_message('medios-nop', function()
|
||||
return
|
||||
@@ -1322,6 +1612,7 @@ end
|
||||
|
||||
-- Prime store cache shortly after load (best-effort; picker also refreshes on-demand).
|
||||
mp.add_timeout(0.10, function()
|
||||
pcall(_ensure_selected_store_loaded)
|
||||
if not _store_cache_loaded then
|
||||
pcall(_refresh_store_cache, 1.5)
|
||||
end
|
||||
@@ -1729,23 +2020,71 @@ if not opts.cli_path then
|
||||
opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py"
|
||||
end
|
||||
|
||||
-- Helper to run pipeline
|
||||
function M.run_pipeline(pipeline_cmd, seeds)
|
||||
local 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 ''
|
||||
_lua_log('Pipeline error: ' .. tostring(err) .. suffix)
|
||||
mp.osd_message('Error: pipeline failed' .. suffix, 6)
|
||||
return nil
|
||||
-- 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
|
||||
|
||||
mp.osd_message('Error: pipeline helper not available', 6)
|
||||
_lua_log('ipc: helper not available; refusing to spawn python subprocess')
|
||||
return nil
|
||||
_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
|
||||
|
||||
-- Helper to run pipeline and parse JSON output
|
||||
@@ -1858,62 +2197,218 @@ end
|
||||
|
||||
-- Prompt for trim range via an input box and callback
|
||||
local function _start_trim_with_range(range)
|
||||
_lua_log('=== TRIM START: range=' .. tostring(range))
|
||||
mp.osd_message('Trimming...', 10)
|
||||
|
||||
-- Load the trim module for direct FFmpeg trimming
|
||||
local script_dir = mp.get_script_directory()
|
||||
_lua_log('trim: script_dir=' .. tostring(script_dir))
|
||||
|
||||
-- Try multiple locations for trim.lua
|
||||
local trim_paths = {}
|
||||
if script_dir and script_dir ~= '' then
|
||||
table.insert(trim_paths, script_dir .. '/trim.lua')
|
||||
table.insert(trim_paths, script_dir .. '/LUA/trim.lua') -- if called from parent
|
||||
table.insert(trim_paths, script_dir .. '/../trim.lua')
|
||||
end
|
||||
|
||||
-- Also try absolute path
|
||||
table.insert(trim_paths, '/medios/Medios-Macina/MPV/LUA/trim.lua')
|
||||
table.insert(trim_paths, 'C:/medios/Medios-Macina/MPV/LUA/trim.lua')
|
||||
|
||||
local trim_module = nil
|
||||
local load_err = nil
|
||||
|
||||
for _, trim_path in ipairs(trim_paths) do
|
||||
_lua_log('trim: trying path=' .. trim_path)
|
||||
local ok, result = pcall(loadfile, trim_path)
|
||||
if ok and result then
|
||||
trim_module = result()
|
||||
_lua_log('trim: loaded successfully from ' .. trim_path)
|
||||
break
|
||||
else
|
||||
load_err = tostring(result or 'unknown error')
|
||||
_lua_log('trim: failed to load from ' .. trim_path .. ' (' .. load_err .. ')')
|
||||
end
|
||||
end
|
||||
|
||||
if not trim_module or not trim_module.trim_file then
|
||||
mp.osd_message('ERROR: Could not load trim module from any path', 3)
|
||||
_lua_log('trim: FAILED - all paths exhausted, last error=' .. tostring(load_err))
|
||||
return
|
||||
end
|
||||
|
||||
range = trim(tostring(range or ''))
|
||||
_lua_log('trim: after_trim range=' .. tostring(range))
|
||||
|
||||
if range == '' then
|
||||
mp.osd_message('Trim cancelled (no range provided)', 3)
|
||||
_lua_log('trim: CANCELLED - empty range')
|
||||
return
|
||||
end
|
||||
|
||||
local target = _current_target()
|
||||
if not target or target == '' then
|
||||
mp.osd_message('No file to trim', 3)
|
||||
_lua_log('trim: FAILED - no target')
|
||||
return
|
||||
end
|
||||
_lua_log('trim: target=' .. tostring(target))
|
||||
|
||||
local store_hash = _extract_store_hash(target)
|
||||
if store_hash then
|
||||
_lua_log('trim: store_hash detected store=' .. tostring(store_hash.store) .. ' hash=' .. tostring(store_hash.hash))
|
||||
else
|
||||
_lua_log('trim: store_hash=nil (local file)')
|
||||
end
|
||||
|
||||
-- Get the selected store (this reads from saved config or mpv property)
|
||||
_ensure_selected_store_loaded()
|
||||
local selected_store = _get_selected_store()
|
||||
-- Strip any existing quotes from the store name
|
||||
selected_store = selected_store:gsub('^"', ''):gsub('"$', '')
|
||||
_lua_log('trim: selected_store=' .. tostring(selected_store or 'NONE'))
|
||||
_lua_log('trim: _cached_store_names=' .. tostring(_cached_store_names and #_cached_store_names or 0))
|
||||
_lua_log('trim: _selected_store_index=' .. tostring(_selected_store_index or 'nil'))
|
||||
|
||||
-- Prefer the resolved stream URL/filename so trimming can avoid full downloads where possible.
|
||||
local stream = trim(tostring(mp.get_property('stream-open-filename') or ''))
|
||||
if stream == '' then
|
||||
stream = tostring(target)
|
||||
end
|
||||
_lua_log('trim: stream=' .. tostring(stream))
|
||||
|
||||
local title = trim(tostring(mp.get_property('media-title') or ''))
|
||||
if title == '' then
|
||||
title = 'clip'
|
||||
end
|
||||
_lua_log('trim: title=' .. tostring(title))
|
||||
|
||||
-- ===== TRIM IN LUA USING FFMPEG =====
|
||||
mp.osd_message('Starting FFmpeg trim...', 1)
|
||||
_lua_log('trim: calling trim_module.trim_file with range=' .. range)
|
||||
|
||||
-- Get temp directory from config or use default
|
||||
local temp_dir = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp'
|
||||
_lua_log('trim: using temp_dir=' .. temp_dir)
|
||||
|
||||
local success, output_path, error_msg = trim_module.trim_file(stream, range, temp_dir)
|
||||
|
||||
if not success then
|
||||
mp.osd_message('Trim failed: ' .. error_msg, 3)
|
||||
_lua_log('trim: FAILED - ' .. error_msg)
|
||||
return
|
||||
end
|
||||
|
||||
_lua_log('trim: FFmpeg SUCCESS - output_path=' .. output_path)
|
||||
mp.osd_message('Trim complete, uploading...', 2)
|
||||
|
||||
-- ===== UPLOAD TO PYTHON FOR STORAGE AND METADATA =====
|
||||
local pipeline_cmd = nil
|
||||
_lua_log('trim: === BUILDING UPLOAD PIPELINE ===')
|
||||
_lua_log('trim: store_hash=' .. tostring(store_hash and (store_hash.store .. '/' .. store_hash.hash) or 'nil'))
|
||||
_lua_log('trim: selected_store=' .. tostring(selected_store or 'nil'))
|
||||
|
||||
local pipeline_cmd
|
||||
if store_hash then
|
||||
-- Original file is from a store - set relationship to it
|
||||
_lua_log('trim: building store file pipeline (original from store)')
|
||||
if selected_store then
|
||||
pipeline_cmd =
|
||||
'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
|
||||
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
|
||||
' | trim-file -input ' .. quote_pipeline_arg(stream) ..
|
||||
' -range ' .. quote_pipeline_arg(range) ..
|
||||
' | add-file -store ' .. quote_pipeline_arg(store_hash.store)
|
||||
' | add-file -path ' .. quote_pipeline_arg(output_path) ..
|
||||
' -store "' .. selected_store .. '"' ..
|
||||
' | add-relationship -store "' .. selected_store .. '"' ..
|
||||
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
|
||||
else
|
||||
if utils.file_info(tostring(target)) then
|
||||
pipeline_cmd = 'trim-file -path ' .. quote_pipeline_arg(target) .. ' -range ' .. quote_pipeline_arg(range)
|
||||
pipeline_cmd =
|
||||
'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) ..
|
||||
' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) ..
|
||||
' | add-file -path ' .. quote_pipeline_arg(output_path) ..
|
||||
' -store "' .. store_hash.store .. '"' ..
|
||||
' | add-relationship -store "' .. store_hash.store .. '"' ..
|
||||
' -to-hash ' .. quote_pipeline_arg(store_hash.hash)
|
||||
end
|
||||
else
|
||||
pipeline_cmd = 'trim-file -input ' .. quote_pipeline_arg(stream) .. ' -range ' .. quote_pipeline_arg(range)
|
||||
-- Local file: save to selected store if available
|
||||
_lua_log('trim: local file pipeline (not from store)')
|
||||
if selected_store then
|
||||
_lua_log('trim: building add-file command to selected_store=' .. selected_store)
|
||||
-- Don't add title if empty - the file path will be used as title by default
|
||||
pipeline_cmd = 'add-file -path ' .. quote_pipeline_arg(output_path) ..
|
||||
' -store "' .. selected_store .. '"'
|
||||
_lua_log('trim: pipeline_cmd=' .. pipeline_cmd)
|
||||
else
|
||||
mp.osd_message('Trim complete: ' .. output_path, 5)
|
||||
_lua_log('trim: no store selected, trim complete at ' .. output_path)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if not _run_pipeline_detached(pipeline_cmd) then
|
||||
M.run_pipeline(pipeline_cmd)
|
||||
if not pipeline_cmd or pipeline_cmd == '' then
|
||||
mp.osd_message('Trim error: could not build upload command', 3)
|
||||
_lua_log('trim: FAILED - empty pipeline_cmd')
|
||||
return
|
||||
end
|
||||
|
||||
_lua_log('trim: final upload_cmd=' .. pipeline_cmd)
|
||||
_lua_log('trim: === CALLING API FOR UPLOAD ===')
|
||||
|
||||
-- Call the API to handle metadata/storage
|
||||
local response = _call_mpv_api({
|
||||
cmd = 'execute_pipeline',
|
||||
pipeline = pipeline_cmd,
|
||||
})
|
||||
|
||||
_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 stderr=' .. tostring(response.stderr or 'nil'))
|
||||
_lua_log('trim: api response returncode=' .. tostring(response.returncode or 'nil'))
|
||||
|
||||
if response.stderr and response.stderr ~= '' then
|
||||
_lua_log('trim: STDERR OUTPUT: ' .. response.stderr)
|
||||
end
|
||||
|
||||
if response.success then
|
||||
local msg = 'Trim and upload completed'
|
||||
if selected_store then
|
||||
msg = msg .. ' (store: ' .. selected_store .. ')'
|
||||
end
|
||||
mp.osd_message(msg, 5)
|
||||
_lua_log('trim: SUCCESS - ' .. msg)
|
||||
else
|
||||
local err_msg = response.error or response.stderr or 'unknown error'
|
||||
mp.osd_message('Upload failed: ' .. err_msg, 5)
|
||||
_lua_log('trim: upload FAILED - ' .. err_msg)
|
||||
end
|
||||
mp.osd_message('Trim started', 3)
|
||||
end
|
||||
|
||||
function M.open_trim_prompt()
|
||||
_lua_log('=== OPEN_TRIM_PROMPT called')
|
||||
|
||||
local marker_range = _get_trim_range_from_clip_markers()
|
||||
_lua_log('trim_prompt: marker_range=' .. tostring(marker_range or 'NONE'))
|
||||
|
||||
if marker_range then
|
||||
_lua_log('trim_prompt: using auto-detected markers, starting trim')
|
||||
mp.osd_message('Using clip markers: ' .. marker_range, 2)
|
||||
_start_trim_with_range(marker_range)
|
||||
return
|
||||
end
|
||||
|
||||
_lua_log('trim_prompt: no clip markers detected, showing prompt')
|
||||
mp.osd_message('Set 2 clip markers with the marker button, or enter range manually', 3)
|
||||
|
||||
local selected_store = _cached_store_names and #_cached_store_names > 0 and _selected_store_index
|
||||
and _cached_store_names[_selected_store_index] or nil
|
||||
local store_hint = selected_store and ' (saving to: ' .. selected_store .. ')' or ' (no store selected; will save locally)'
|
||||
|
||||
local menu_data = {
|
||||
type = TRIM_PROMPT_MENU_TYPE,
|
||||
title = 'Trim file',
|
||||
search_style = 'palette',
|
||||
search_debounce = 'submit',
|
||||
on_search = 'callback',
|
||||
footnote = "Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter",
|
||||
footnote = "Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter" .. store_hint,
|
||||
callback = { mp.get_script_name(), 'medios-trim-run' },
|
||||
items = {
|
||||
{
|
||||
|
||||
204
MPV/LUA/trim.lua
Normal file
204
MPV/LUA/trim.lua
Normal file
@@ -0,0 +1,204 @@
|
||||
-- Trim file directly in MPV using FFmpeg
|
||||
-- This script handles the actual video trimming with FFmpeg subprocess
|
||||
-- Then passes the trimmed file to Python for upload/metadata handling
|
||||
|
||||
local mp = require "mp"
|
||||
local utils = require "mp.utils"
|
||||
|
||||
local trim = {}
|
||||
|
||||
-- Configuration for trim presets
|
||||
trim.config = {
|
||||
output_dir = os.getenv('TEMP') or os.getenv('TMP') or '/tmp', -- use temp dir by default
|
||||
video_codec = "copy", -- lossless by default
|
||||
audio_codec = "copy",
|
||||
container = "auto",
|
||||
audio_bitrate = "",
|
||||
osd_duration = 2000,
|
||||
}
|
||||
|
||||
-- Quality presets for video trimming
|
||||
trim.presets = {
|
||||
copy = { video_codec="copy", audio_codec="copy" },
|
||||
high = { video_codec="libx264", crf="18", preset="slower", audio_codec="aac", audio_bitrate="192k" },
|
||||
medium = { video_codec="libx264", crf="20", preset="medium", audio_codec="aac", audio_bitrate="128k" },
|
||||
fast = { video_codec="libx264", crf="23", preset="fast", audio_codec="aac", audio_bitrate="96k" },
|
||||
tiny = { video_codec="libx264", crf="28", preset="ultrafast", audio_codec="aac", audio_bitrate="64k" },
|
||||
}
|
||||
|
||||
trim.current_quality = "copy"
|
||||
|
||||
-- Get active preset with current quality
|
||||
local function _get_active_preset()
|
||||
local preset = trim.presets[trim.current_quality] or {}
|
||||
local merged = {}
|
||||
for k, v in pairs(preset) do
|
||||
merged[k] = v
|
||||
end
|
||||
for k, v in pairs(trim.config) do
|
||||
if merged[k] == nil then
|
||||
merged[k] = v
|
||||
end
|
||||
end
|
||||
return merged
|
||||
end
|
||||
|
||||
-- Extract title from file path, handling special URL formats
|
||||
local function _parse_file_title(filepath)
|
||||
-- For torrent URLs, try to extract meaningful filename
|
||||
if filepath:match("torrentio%.strem%.fun") then
|
||||
-- Format: https://torrentio.strem.fun/resolve/alldebrid/.../filename/0/filename
|
||||
local filename = filepath:match("([^/]+)/0/[^/]+$")
|
||||
if filename then
|
||||
filename = filename:gsub("%.mkv$", ""):gsub("%.mp4$", ""):gsub("%.avi$", "")
|
||||
return filename
|
||||
end
|
||||
end
|
||||
|
||||
-- Standard file path
|
||||
local dir, name = utils.split_path(filepath)
|
||||
return name:gsub("%..+$", "")
|
||||
end
|
||||
|
||||
-- Format time duration as "1h3m-1h3m15s"
|
||||
local function _format_time_range(start_sec, end_sec)
|
||||
local function _sec_to_str(sec)
|
||||
local h = math.floor(sec / 3600)
|
||||
local m = math.floor((sec % 3600) / 60)
|
||||
local s = math.floor(sec % 60)
|
||||
|
||||
local parts = {}
|
||||
if h > 0 then table.insert(parts, h .. "h") end
|
||||
if m > 0 then table.insert(parts, m .. "m") end
|
||||
if s > 0 or #parts == 0 then table.insert(parts, s .. "s") end
|
||||
|
||||
return table.concat(parts)
|
||||
end
|
||||
|
||||
return _sec_to_str(start_sec) .. "-" .. _sec_to_str(end_sec)
|
||||
end
|
||||
|
||||
-- Trim file using FFmpeg with range in format "1h3m-1h3m15s"
|
||||
-- Returns: (success, output_path, error_msg)
|
||||
function trim.trim_file(input_file, range_str, temp_dir)
|
||||
if not input_file or input_file == "" then
|
||||
return false, nil, "No input file specified"
|
||||
end
|
||||
|
||||
if not range_str or range_str == "" then
|
||||
return false, nil, "No range specified (format: 1h3m-1h3m15s)"
|
||||
end
|
||||
|
||||
-- Use provided temp_dir or fall back to config
|
||||
if not temp_dir or temp_dir == "" then
|
||||
temp_dir = trim.config.output_dir
|
||||
end
|
||||
|
||||
-- Parse range string "1h3m-1h3m15s"
|
||||
local start_str, end_str = range_str:match("^([^-]+)-(.+)$")
|
||||
if not start_str or not end_str then
|
||||
return false, nil, "Invalid range format. Use: 1h3m-1h3m15s"
|
||||
end
|
||||
|
||||
-- Convert time string to seconds
|
||||
local function _parse_time(time_str)
|
||||
local sec = 0
|
||||
local h = time_str:match("(%d+)h")
|
||||
local m = time_str:match("(%d+)m")
|
||||
local s = time_str:match("(%d+)s")
|
||||
|
||||
if h then sec = sec + tonumber(h) * 3600 end
|
||||
if m then sec = sec + tonumber(m) * 60 end
|
||||
if s then sec = sec + tonumber(s) end
|
||||
|
||||
return sec
|
||||
end
|
||||
|
||||
local start_time = _parse_time(start_str)
|
||||
local end_time = _parse_time(end_str)
|
||||
local duration = end_time - start_time
|
||||
|
||||
if duration <= 0 then
|
||||
return false, nil, "Invalid range: end time must be after start time"
|
||||
end
|
||||
|
||||
-- Prepare output path
|
||||
local dir, name = utils.split_path(input_file)
|
||||
|
||||
-- If input is a URL, extract filename from URL path
|
||||
if input_file:match("^https?://") then
|
||||
-- For URLs, try to extract the meaningful filename
|
||||
name = input_file:match("([^/]+)$") or "stream"
|
||||
dir = trim.config.output_dir
|
||||
end
|
||||
|
||||
local out_dir = trim.config.output_dir
|
||||
local ext = (trim.config.container == "auto") and input_file:match("^.+(%..+)$") or ("." .. trim.config.container)
|
||||
local base_name = name:gsub("%..+$", "")
|
||||
local out_path = utils.join_path(out_dir, base_name .. "_" .. range_str .. ext)
|
||||
|
||||
-- Normalize path to use consistent backslashes on Windows
|
||||
out_path = out_path:gsub("/", "\\")
|
||||
|
||||
-- Build FFmpeg command
|
||||
local p = _get_active_preset()
|
||||
local args = { "ffmpeg", "-y", "-ss", tostring(start_time), "-i", input_file, "-t", tostring(duration) }
|
||||
|
||||
-- Video codec
|
||||
if p.video_codec == "copy" then
|
||||
table.insert(args, "-c:v")
|
||||
table.insert(args, "copy")
|
||||
else
|
||||
table.insert(args, "-c:v")
|
||||
table.insert(args, p.video_codec)
|
||||
if p.crf and p.crf ~= "" then
|
||||
table.insert(args, "-crf")
|
||||
table.insert(args, p.crf)
|
||||
end
|
||||
if p.preset and p.preset ~= "" then
|
||||
table.insert(args, "-preset")
|
||||
table.insert(args, p.preset)
|
||||
end
|
||||
end
|
||||
|
||||
-- Audio codec
|
||||
if p.audio_codec == "copy" then
|
||||
table.insert(args, "-c:a")
|
||||
table.insert(args, "copy")
|
||||
else
|
||||
table.insert(args, "-c:a")
|
||||
table.insert(args, p.audio_codec)
|
||||
if p.audio_bitrate and p.audio_bitrate ~= "" then
|
||||
table.insert(args, "-b:a")
|
||||
table.insert(args, p.audio_bitrate)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(args, out_path)
|
||||
|
||||
-- Execute FFmpeg synchronously
|
||||
local result = mp.command_native({ name = "subprocess", args = args, capture_stdout = true, capture_stderr = true })
|
||||
|
||||
if not result or result.status ~= 0 then
|
||||
local error_msg = result and result.stderr or "Unknown FFmpeg error"
|
||||
return false, nil, "FFmpeg failed: " .. error_msg
|
||||
end
|
||||
|
||||
return true, out_path, nil
|
||||
end
|
||||
|
||||
-- Cycle to next quality preset
|
||||
function trim.cycle_quality()
|
||||
local presets = { "copy", "high", "medium", "fast", "tiny" }
|
||||
local idx = 1
|
||||
for i, v in ipairs(presets) do
|
||||
if v == trim.current_quality then
|
||||
idx = i
|
||||
break
|
||||
end
|
||||
end
|
||||
trim.current_quality = presets[(idx % #presets) + 1]
|
||||
return trim.current_quality
|
||||
end
|
||||
|
||||
return trim
|
||||
173
MPV/mpv_lua_api.py
Normal file
173
MPV/mpv_lua_api.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""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)
|
||||
@@ -40,6 +40,21 @@ def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _runtime_config_root() -> Path:
|
||||
"""Best-effort config root for runtime execution.
|
||||
|
||||
MPV can spawn this helper from an installed location while setting `cwd` to
|
||||
the repo root (see MPV.mpv_ipc). Prefer `cwd` when it contains `config.conf`.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd().resolve()
|
||||
if (cwd / "config.conf").exists():
|
||||
return cwd
|
||||
except Exception:
|
||||
pass
|
||||
return _repo_root()
|
||||
|
||||
|
||||
# Make repo-local packages importable even when mpv starts us from another cwd.
|
||||
_ROOT = str(_repo_root())
|
||||
if _ROOT not in sys.path:
|
||||
@@ -223,10 +238,39 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
|
||||
|
||||
# Provide store backend choices using the same source as CLI/Typer autocomplete.
|
||||
if op_name in {"store-choices", "store_choices", "get-store-choices", "get_store_choices"}:
|
||||
from CLI import MedeiaCLI # noqa: WPS433
|
||||
# IMPORTANT:
|
||||
# - Prefer runtime cwd for config discovery (mpv spawns us with cwd=repo_root).
|
||||
# - Avoid returning a cached empty result if config was loaded before it existed.
|
||||
try:
|
||||
from config import reload_config # noqa: WPS433
|
||||
from Store import Store # noqa: WPS433
|
||||
|
||||
backends = MedeiaCLI.get_store_choices()
|
||||
choices = sorted({str(n) for n in (backends or []) if str(n).strip()})
|
||||
config_root = _runtime_config_root()
|
||||
cfg = reload_config(config_dir=config_root)
|
||||
|
||||
storage = Store(config=cfg, suppress_debug=True)
|
||||
backends = storage.list_backends() or []
|
||||
choices = sorted({str(n) for n in backends if str(n).strip()})
|
||||
|
||||
# Fallback: if initialization gated all backends (e.g., missing deps or offline stores),
|
||||
# still return configured instance names so the UI can present something.
|
||||
if not choices:
|
||||
store_cfg = cfg.get("store") if isinstance(cfg, dict) else None
|
||||
if isinstance(store_cfg, dict):
|
||||
seen = set()
|
||||
for _, instances in store_cfg.items():
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
for instance_key, instance_cfg in instances.items():
|
||||
name = None
|
||||
if isinstance(instance_cfg, dict):
|
||||
name = instance_cfg.get("NAME") or instance_cfg.get("name")
|
||||
candidate = (str(name or instance_key or "").strip())
|
||||
if candidate:
|
||||
seen.add(candidate)
|
||||
choices = sorted(seen)
|
||||
|
||||
debug(f"[store-choices] config_dir={config_root} choices={len(choices)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -236,6 +280,15 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
|
||||
"table": None,
|
||||
"choices": choices,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": f"store-choices failed: {type(exc).__name__}: {exc}",
|
||||
"table": None,
|
||||
"choices": [],
|
||||
}
|
||||
|
||||
# Provide yt-dlp format list for a URL (for MPV "Change format" menu).
|
||||
# Returns a ResultTable-like payload so the Lua UI can render without running cmdlets.
|
||||
@@ -580,6 +633,17 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
|
||||
try:
|
||||
_append_helper_log(f"[helper] version={MEDEIA_MPV_HELPER_VERSION} started ipc={args.ipc}")
|
||||
try:
|
||||
_append_helper_log(f"[helper] file={Path(__file__).resolve()} cwd={Path.cwd().resolve()}")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
runtime_root = _runtime_config_root()
|
||||
_append_helper_log(
|
||||
f"[helper] config_root={runtime_root} exists={bool((runtime_root / 'config.conf').exists())}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
debug(f"[mpv-helper] logging to: {_helper_log_path()}")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -679,13 +743,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
if (now - last_ready_ts) < 0.75:
|
||||
return
|
||||
try:
|
||||
client.send_command_no_wait(["set_property", READY_PROP, str(int(now))])
|
||||
client.send_command_no_wait(["set_property_string", READY_PROP, str(int(now))])
|
||||
last_ready_ts = now
|
||||
except Exception:
|
||||
return
|
||||
|
||||
_touch_ready()
|
||||
|
||||
# Mirror mpv's own log messages into our helper log file so debugging does
|
||||
# not depend on the mpv on-screen console or mpv's log-file.
|
||||
try:
|
||||
@@ -715,6 +777,46 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
except Exception:
|
||||
return 3
|
||||
|
||||
# Mark ready only after the observer is installed to avoid races where Lua
|
||||
# sends a request before we can receive property-change notifications.
|
||||
try:
|
||||
_touch_ready()
|
||||
_append_helper_log(f"[helper] ready heartbeat armed prop={READY_PROP}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Pre-compute store choices at startup and publish to a cached property so Lua
|
||||
# can read immediately without waiting for a request/response cycle (which may timeout).
|
||||
try:
|
||||
startup_choices_payload = _run_op("store-choices", None)
|
||||
startup_choices = startup_choices_payload.get("choices") if isinstance(startup_choices_payload, dict) else None
|
||||
if isinstance(startup_choices, list):
|
||||
preview = ", ".join(str(x) for x in startup_choices[:50])
|
||||
_append_helper_log(f"[helper] startup store-choices count={len(startup_choices)} items={preview}")
|
||||
|
||||
# Publish to a cached property for Lua to read without IPC request.
|
||||
try:
|
||||
cached_json = json.dumps({"success": True, "choices": startup_choices}, ensure_ascii=False)
|
||||
client.send_command_no_wait(["set_property_string", "user-data/medeia-store-choices-cached", cached_json])
|
||||
_append_helper_log(f"[helper] published store-choices to user-data/medeia-store-choices-cached")
|
||||
except Exception as exc:
|
||||
_append_helper_log(f"[helper] failed to publish store-choices: {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
_append_helper_log("[helper] startup store-choices unavailable")
|
||||
except Exception as exc:
|
||||
_append_helper_log(f"[helper] startup store-choices failed: {type(exc).__name__}: {exc}")
|
||||
|
||||
# Also publish config temp directory if available
|
||||
try:
|
||||
from config import load_config
|
||||
cfg = load_config()
|
||||
temp_dir = cfg.get("temp", "").strip() or os.getenv("TEMP") or "/tmp"
|
||||
if temp_dir:
|
||||
client.send_command_no_wait(["set_property_string", "user-data/medeia-config-temp", temp_dir])
|
||||
_append_helper_log(f"[helper] published config temp to user-data/medeia-config-temp={temp_dir}")
|
||||
except Exception as exc:
|
||||
_append_helper_log(f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}")
|
||||
|
||||
last_seen_id: Optional[str] = None
|
||||
|
||||
try:
|
||||
@@ -864,7 +966,7 @@ def main(argv: Optional[list[str]] = None) -> int:
|
||||
try:
|
||||
# IMPORTANT: don't wait for a response here; waiting would consume
|
||||
# async events and can drop/skip property-change notifications.
|
||||
client.send_command_no_wait(["set_property", RESPONSE_PROP, json.dumps(resp, ensure_ascii=False)])
|
||||
client.send_command_no_wait(["set_property_string", RESPONSE_PROP, json.dumps(resp, ensure_ascii=False)])
|
||||
except Exception:
|
||||
# If posting results fails, there's nothing more useful to do.
|
||||
pass
|
||||
|
||||
2
MPV/portable_config/script-opts/medeia.conf
Normal file
2
MPV/portable_config/script-opts/medeia.conf
Normal file
@@ -0,0 +1,2 @@
|
||||
# Medeia MPV script options
|
||||
store=video
|
||||
@@ -84,7 +84,7 @@ progress_line_width=20
|
||||
# fullscreen = cycle:crop_free:fullscreen:no/yes=fullscreen_exit!?Fullscreen
|
||||
# loop-playlist = cycle:repeat:loop-playlist:no/inf!?Loop playlist
|
||||
# toggle:{icon}:{prop} = cycle:{icon}:{prop}:no/yes!
|
||||
controls=menu,gap,<video,audio>subtitles,<has_many_audio>audio,<has_many_video>video,<has_many_edition>editions,gap,shuffle,gap,prev,items,next,space,command:photo_camera:script-message medeia-image-screenshot?Screenshot,command:content_cut:script-message medeia-image-clip?Clip Marker,command:headset:script-message medeia-audio-only?Audio
|
||||
controls=menu,gap,<video,audio>subtitles,<has_many_audio>audio,<has_many_video>video,<has_many_edition>editions,gap,shuffle,gap,prev,items,next,space,command:photo_camera:script-message medeia-image-screenshot?Screenshot,command:content_cut:script-message medeia-image-clip?Clip Marker,command:headset:script-message medeia-audio-only?Audio,command:store:script-message medeia-store-picker?Store
|
||||
controls_size=32
|
||||
controls_margin=8
|
||||
controls_spacing=2
|
||||
|
||||
@@ -145,6 +145,20 @@ 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
|
||||
|
||||
@@ -156,12 +156,34 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
|
||||
try:
|
||||
parsed = urlparse(str(url))
|
||||
host = (parsed.hostname or "").strip().lower()
|
||||
path = (parsed.path or "").strip()
|
||||
except Exception:
|
||||
host = ""
|
||||
path = ""
|
||||
|
||||
if not host:
|
||||
return None
|
||||
|
||||
# Prefer Internet Archive for archive.org links unless the URL clearly refers
|
||||
# to a borrow/loan flow (handled by OpenLibrary provider).
|
||||
#
|
||||
# This keeps direct downloads and item pages routed to `internetarchive`, while
|
||||
# preserving OpenLibrary's scripted borrow pipeline for loan/reader URLs.
|
||||
if host == "openlibrary.org" or host.endswith(".openlibrary.org"):
|
||||
return "openlibrary" if "openlibrary" in _PROVIDERS else None
|
||||
|
||||
if host == "archive.org" or host.endswith(".archive.org"):
|
||||
low_path = str(path or "").lower()
|
||||
is_borrowish = (
|
||||
low_path.startswith("/borrow/")
|
||||
or low_path.startswith("/stream/")
|
||||
or low_path.startswith("/services/loans/")
|
||||
or "/services/loans/" in low_path
|
||||
)
|
||||
if is_borrowish:
|
||||
return "openlibrary" if "openlibrary" in _PROVIDERS else None
|
||||
return "internetarchive" if "internetarchive" in _PROVIDERS else None
|
||||
|
||||
for name, provider_class in _PROVIDERS.items():
|
||||
domains = getattr(provider_class, "URL_DOMAINS", None)
|
||||
if not isinstance(domains, (list, tuple)):
|
||||
|
||||
Reference in New Issue
Block a user