From 71b542ae91771b39404a8baba61e9c520ba2a22d Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 27 Dec 2025 03:13:16 -0800 Subject: [PATCH] fd --- MPV/LUA/main.lua | 577 ++++++++++++++++-- MPV/LUA/trim.lua | 204 +++++++ MPV/mpv_lua_api.py | 173 ++++++ MPV/pipeline_helper.py | 132 +++- MPV/portable_config/script-opts/medeia.conf | 2 + MPV/portable_config/script-opts/uosc.conf | 2 +- .../uosc/scripts/uosc/elements/Controls.lua | 14 + ProviderCore/registry.py | 22 + 8 files changed, 1069 insertions(+), 57 deletions(-) create mode 100644 MPV/LUA/trim.lua create mode 100644 MPV/mpv_lua_api.py create mode 100644 MPV/portable_config/script-opts/medeia.conf diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index e79b871..8ce5950 100644 --- a/MPV/LUA/main.lua +++ b/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 /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 +-- 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 - 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 + + _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 - - mp.osd_message('Error: pipeline helper not available', 6) - _lua_log('ipc: helper not available; refusing to spawn python subprocess') - return nil 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 pipeline_cmd + 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')) + if store_hash 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) - else - if utils.file_info(tostring(target)) then - pipeline_cmd = 'trim-file -path ' .. quote_pipeline_arg(target) .. ' -range ' .. quote_pipeline_arg(range) + -- 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) .. + ' | 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 - pipeline_cmd = 'trim-file -input ' .. quote_pipeline_arg(stream) .. ' -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 + -- 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 = { { diff --git a/MPV/LUA/trim.lua b/MPV/LUA/trim.lua new file mode 100644 index 0000000..8ea2959 --- /dev/null +++ b/MPV/LUA/trim.lua @@ -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 diff --git a/MPV/mpv_lua_api.py b/MPV/mpv_lua_api.py new file mode 100644 index 0000000..a17a4a4 --- /dev/null +++ b/MPV/mpv_lua_api.py @@ -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 + + if len(sys.argv) < 2: + print(json.dumps({"success": False, "error": "No request provided"})) + sys.exit(1) + + request_json = sys.argv[1] + log_file = Path(sys.argv[2]) if len(sys.argv) > 2 else None + + response = handle_api_request(request_json, log_file) + print(response) diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index bb784b0..69ef2bc 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -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,19 +238,57 @@ 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) - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "choices": choices, - } + 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, + "stdout": "", + "stderr": "", + "error": None, + "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 diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf new file mode 100644 index 0000000..89db4c9 --- /dev/null +++ b/MPV/portable_config/script-opts/medeia.conf @@ -0,0 +1,2 @@ +# Medeia MPV script options +store=video diff --git a/MPV/portable_config/script-opts/uosc.conf b/MPV/portable_config/script-opts/uosc.conf index 4f84c60..ef324ba 100644 --- a/MPV/portable_config/script-opts/uosc.conf +++ b/MPV/portable_config/script-opts/uosc.conf @@ -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,subtitles,audio,video,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,subtitles,audio,video,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 diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua b/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua index b134c33..86f1973 100644 --- a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua +++ b/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua @@ -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 diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index 4286957..658f6a6 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -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)):