fd
Some checks failed
smoke-mm / Install & smoke test mm --help (push) Has been cancelled

This commit is contained in:
2025-12-27 03:13:16 -08:00
parent a595453a9b
commit 71b542ae91
8 changed files with 1069 additions and 57 deletions

View File

@@ -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
View 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
View 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)

View File

@@ -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

View File

@@ -0,0 +1,2 @@
# Medeia MPV script options
store=video

View File

@@ -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

View File

@@ -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

View File

@@ -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)):