local mp = require 'mp' local utils = require 'mp.utils' local msg = require 'mp.msg' local M = {} local MEDEIA_LUA_VERSION = '2026-03-20.1' -- 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 mp.register_script_message('uosc-version', function(_ver) _uosc_loaded = true end) local function _is_script_loaded(name) local ok, list = pcall(mp.get_property_native, 'script-list') if not ok or type(list) ~= 'table' then return false end for _, s in ipairs(list) do if type(s) == 'table' then local n = s.name or '' if n == name or tostring(n):match('^' .. name .. '%d*$') then return true end elseif type(s) == 'string' then local n = s if n == name or tostring(n):match('^' .. name .. '%d*$') then return true end end end return false end local LOAD_URL_MENU_TYPE = 'medios_load_url' local DOWNLOAD_FORMAT_MENU_TYPE = 'medios_download_pick_format' local DOWNLOAD_STORE_MENU_TYPE = 'medios_download_pick_store' local SCREENSHOT_TAG_MENU_TYPE = 'medeia_screenshot_tags' local SCREENSHOT_SAVE_JOB_TIMEOUT = 120 local SCREENSHOT_SAVE_JOB_POLL_INTERVAL = 0.75 -- Menu types for the command submenu and trim prompt local CMD_MENU_TYPE = 'medios_cmd_menu' local TRIM_PROMPT_MENU_TYPE = 'medios_trim_prompt' local SLEEP_PROMPT_MENU_TYPE = 'medeia_sleep_timer_prompt' local _sleep_timer = nil local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request' local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response' local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready' local CURRENT_WEB_URL_PROP = 'user-data/medeia-current-web-url' local function _get_lua_source_path() local info = nil pcall(function() info = debug.getinfo(1, 'S') end) local source = info and info.source or '' if type(source) == 'string' and source:sub(1, 1) == '@' then return source:sub(2) end return '' end local function _detect_repo_root() local function find_up(start_dir, relative_path, max_levels) local d = start_dir local levels = max_levels or 8 for _ = 0, levels do if d and d ~= '' then local candidate = d .. '/' .. relative_path if utils.file_info(candidate) then return candidate end end local parent = d and d:match('(.*)[/\\]') or nil if not parent or parent == d or parent == '' then break end d = parent end return nil end local bases = { (_get_lua_source_path():match('(.*)[/\\]') or ''), mp.get_script_directory() or '', utils.getcwd() or '', ((opts and opts.cli_path) and tostring(opts.cli_path):match('(.*)[/\\]') or ''), } for _, base in ipairs(bases) do if base ~= '' then local cli = find_up(base, 'CLI.py', 8) if cli and cli ~= '' then return cli:match('(.*)[/\\]') or '' end end end return '' end local function _append_lua_log_file(payload) payload = tostring(payload or '') payload = payload:gsub('^%s+', ''):gsub('%s+$', '') if payload == '' then return nil end local log_dir = '' local repo_root = _detect_repo_root() if repo_root ~= '' then log_dir = utils.join_path(repo_root, 'Log') end if log_dir == '' then log_dir = os.getenv('TEMP') or os.getenv('TMP') or utils.getcwd() or '' end if log_dir == '' then return nil end local path = utils.join_path(log_dir, 'medeia-mpv-lua.log') local fh = io.open(path, 'a') if not fh and repo_root ~= '' then local tmp = os.getenv('TEMP') or os.getenv('TMP') or '' if tmp ~= '' then path = utils.join_path(tmp, 'medeia-mpv-lua.log') fh = io.open(path, 'a') end end if not fh then return nil end fh:write('[' .. tostring(os.date('%Y-%m-%d %H:%M:%S')) .. '] ' .. payload .. '\n') fh:close() return path end local function _emit_to_mpv_log(payload) payload = tostring(payload or '') if payload == '' then return end local text = '[medeia] ' .. payload local lower = payload:lower() if lower:find('[error]', 1, true) or lower:find('helper not running', 1, true) or lower:find('failed', 1, true) or lower:find('timeout', 1, true) then pcall(msg.error, text) return end if lower:find('[warn]', 1, true) or lower:find('warning', 1, true) then pcall(msg.warn, text) return end pcall(msg.verbose, text) end -- Dedicated Lua log: write directly to logs.db database for unified logging -- Fallback to stderr if database unavailable local function _lua_log(text) local payload = (text and tostring(text) or '') if payload == '' then return end _append_lua_log_file(payload) _emit_to_mpv_log(payload) -- Attempt to find repo root for database access local repo_root = _detect_repo_root() -- Write to logs.db via Python subprocess (non-blocking, async) if repo_root ~= '' then local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local db_path = (repo_root .. '/logs.db'):gsub('\\', '/') local msg = payload:gsub('\\', '\\\\'):gsub("'", "\\'") local script = string.format( "import sqlite3; p='%s'; c=sqlite3.connect(p); c.execute(\"CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, level TEXT, module TEXT, message TEXT)\"); c.execute(\"INSERT INTO logs (level,module,message) VALUES (?,?,?)\", ('DEBUG','mpv','%s')); c.commit(); c.close()", db_path, msg ) pcall(function() mp.command_native_async({ name = 'subprocess', args = { python, '-c', script } }, function() end) end) end end _lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name())) -- Combined log: to database (primary) + _lua_log (which also writes to db) local function _log_all(level, text) if not text or text == '' then return end level = tostring(level or 'INFO'):upper() text = tostring(text) -- Log with level prefix via _lua_log (which writes to database) _lua_log('[' .. level .. '] ' .. text) end local function ensure_uosc_loaded() if _uosc_loaded or _is_script_loaded('uosc') then _uosc_loaded = true return true end local entry = nil pcall(function() entry = mp.find_config_file('scripts/uosc.lua') end) if not entry or entry == '' then _lua_log('uosc entry not found at scripts/uosc.lua under config-dir') return false end local ok = pcall(mp.commandv, 'load-script', entry) if ok then _lua_log('Loaded uosc from: ' .. tostring(entry)) else _lua_log('Failed to load uosc from: ' .. tostring(entry)) end -- uosc will broadcast uosc-version on load; also re-check script-list if available. if _is_script_loaded('uosc') then _uosc_loaded = true return true end return _uosc_loaded end local function write_temp_log(prefix, text) if not text or text == '' then return nil end local dir = '' -- Prefer repo-root Log/ for easier discovery. -- NOTE: Avoid spawning cmd.exe/sh just to mkdir on Windows/Linux; console flashes are -- highly undesirable. If the directory doesn't exist, we fall back to TEMP. do local function find_up(start_dir, relative_path, max_levels) local d = start_dir local levels = max_levels or 6 for _ = 0, levels do if d and d ~= '' then local candidate = d .. '/' .. relative_path if utils.file_info(candidate) then return candidate end end local parent = d and d:match('(.*)[/\\]') or nil if not parent or parent == d or parent == '' then break end d = parent end return nil end local base = mp.get_script_directory() or utils.getcwd() or '' if base ~= '' then local cli = find_up(base, 'CLI.py', 6) if cli and cli ~= '' then local parent = cli:match('(.*)[/\\]') or '' if parent ~= '' then dir = utils.join_path(parent, 'Log') end end end end if dir == '' then dir = os.getenv('TEMP') or os.getenv('TMP') or utils.getcwd() or '' end if dir == '' then return nil end local name = (prefix or 'medeia-mpv') .. '-' .. tostring(math.floor(mp.get_time() * 1000)) .. '.log' local path = utils.join_path(dir, name) local fh = io.open(path, 'w') if not fh then -- If Log/ wasn't created (or is not writable), fall back to TEMP. local tmp = os.getenv('TEMP') or os.getenv('TMP') or '' if tmp ~= '' and tmp ~= dir then path = utils.join_path(tmp, name) fh = io.open(path, 'w') end if not fh then return nil end end fh:write(text) fh:close() return path end local function trim(s) return (s:gsub('^%s+', ''):gsub('%s+$', '')) end -- Lyrics overlay toggle -- The Python helper (python -m MPV.lyric) will read this property via IPC. local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible" local function lyric_get_visible() local ok, v = pcall(mp.get_property_native, LYRIC_VISIBLE_PROP) if not ok or v == nil then return true end return v and true or false end local function lyric_set_visible(v) pcall(mp.set_property_native, LYRIC_VISIBLE_PROP, v and true or false) end local function lyric_toggle() local now = not lyric_get_visible() lyric_set_visible(now) mp.osd_message("Lyrics: " .. (now and "on" or "off"), 1) end -- Default to visible unless user overrides. lyric_set_visible(true) -- Configuration (global so _lua_log can see python_path early) opts = { python_path = "python", cli_path = nil -- Will be auto-detected if nil } -- Read script options from script-opts/medeia.conf when available pcall(function() local mpopts = require('mp.options') mpopts.read_options(opts, 'medeia') end) local function find_file_upwards(start_dir, relative_path, max_levels) local dir = start_dir local levels = max_levels or 6 for _ = 0, levels do if dir and dir ~= "" then local candidate = dir .. "/" .. relative_path if utils.file_info(candidate) then return candidate end end local parent = dir and dir:match("(.*)[/\\]") or nil if not parent or parent == dir or parent == "" then break end dir = parent end return nil end local function _append_unique_path(out, seen, path) path = trim(tostring(path or '')) if path == '' then return end local key = path:gsub('\\', '/'):lower() if seen[key] then return end seen[key] = true out[#out + 1] = path end local function _path_exists(path) path = trim(tostring(path or '')) if path == '' then return false end return utils.file_info(path) ~= nil end local function _normalize_fs_path(path) path = trim(tostring(path or '')) path = path:gsub('^"+', ''):gsub('"+$', '') return trim(path) end local function _build_python_candidates(configured_python, prefer_no_console) local candidates = {} local seen = {} local function add(path) path = trim(tostring(path or '')) if path == '' then return end local key = path:gsub('\\', '/'):lower() if seen[key] then return end seen[key] = true candidates[#candidates + 1] = path end local repo_root = _detect_repo_root() if repo_root ~= '' then local sep = package and package.config and package.config:sub(1, 1) or '/' if sep == '\\' then if prefer_no_console then add(repo_root .. '/.venv/Scripts/pythonw.exe') add(repo_root .. '/venv/Scripts/pythonw.exe') end add(repo_root .. '/.venv/Scripts/python.exe') add(repo_root .. '/venv/Scripts/python.exe') else add(repo_root .. '/.venv/bin/python3') add(repo_root .. '/.venv/bin/python') add(repo_root .. '/venv/bin/python3') add(repo_root .. '/venv/bin/python') end end if _path_exists(configured_python) then if prefer_no_console then local sep = package and package.config and package.config:sub(1, 1) or '/' if sep == '\\' then local low = configured_python:lower() if low:sub(-10) == 'python.exe' then local pythonw = configured_python:sub(1, #configured_python - 10) .. 'pythonw.exe' if _path_exists(pythonw) then add(pythonw) end end end end add(configured_python) elseif configured_python ~= '' and configured_python ~= 'python' and configured_python ~= 'python.exe' then add(configured_python) end local sep = package and package.config and package.config:sub(1, 1) or '/' if sep == '\\' then add('python') add('py') else add('python3') add('python') end return candidates end local function _detect_format_probe_script() local repo_root = _detect_repo_root() if repo_root ~= '' then local direct = utils.join_path(repo_root, 'MPV/format_probe.py') if _path_exists(direct) then return direct end end local candidates = {} local seen = {} local source_dir = _get_lua_source_path():match('(.*)[/\\]') or '' local script_dir = mp.get_script_directory() or '' local cwd = utils.getcwd() or '' _append_unique_path(candidates, seen, find_file_upwards(source_dir, 'MPV/format_probe.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/format_probe.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/format_probe.py', 8)) for _, candidate in ipairs(candidates) do if _path_exists(candidate) then return candidate end end return '' end local function _describe_subprocess_result(result) if result == nil then return 'result=nil' end if type(result) ~= 'table' then return 'result=' .. tostring(result) end local parts = {} if result.error ~= nil then parts[#parts + 1] = 'error=' .. tostring(result.error) end if result.status ~= nil then parts[#parts + 1] = 'status=' .. tostring(result.status) end if result.killed_by_us ~= nil then parts[#parts + 1] = 'killed=' .. tostring(result.killed_by_us) end local stderr = trim(tostring(result.stderr or '')) if stderr ~= '' then stderr = stderr:gsub('[\r\n]+', ' ') if #stderr > 240 then stderr = stderr:sub(1, 240) .. '...' end parts[#parts + 1] = 'stderr=' .. stderr end local stdout = trim(tostring(result.stdout or '')) if stdout ~= '' then stdout = stdout:gsub('[\r\n]+', ' ') if #stdout > 160 then stdout = stdout:sub(1, 160) .. '...' end parts[#parts + 1] = 'stdout=' .. stdout end if #parts == 0 then return 'result={}' end return table.concat(parts, ', ') end local function _run_subprocess_command(cmd) local ok, result = pcall(mp.command_native, cmd) if not ok then return false, nil, tostring(result) end if type(result) == 'table' then local err = trim(tostring(result.error or '')) local status = tonumber(result.status) if (err ~= '' and err ~= 'success') or (status ~= nil and status ~= 0) then return false, result, _describe_subprocess_result(result) end end return true, result, _describe_subprocess_result(result) end local function _build_sibling_script_candidates(file_name) local candidates = {} local seen = {} local script_dir = mp.get_script_directory() or '' local cwd = utils.getcwd() or '' if script_dir ~= '' then _append_unique_path(candidates, seen, script_dir .. '/' .. file_name) _append_unique_path(candidates, seen, script_dir .. '/LUA/' .. file_name) _append_unique_path(candidates, seen, script_dir .. '/../' .. file_name) _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/LUA/' .. file_name, 8)) end if cwd ~= '' then _append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/LUA/' .. file_name, 8)) end return candidates end local function _load_lua_chunk_from_candidates(label, file_name) local candidates = _build_sibling_script_candidates(file_name) local last_error = nil for _, candidate in ipairs(candidates) do local ok_load, chunk_or_err, load_err = pcall(loadfile, candidate) if ok_load and chunk_or_err then local ok_run, result_or_err = pcall(chunk_or_err) if ok_run then _lua_log(label .. ': loaded from ' .. candidate) return true, result_or_err, candidate end last_error = tostring(result_or_err or 'runtime error') _lua_log(label .. ': runtime error at ' .. candidate .. ' (' .. last_error .. ')') elseif ok_load then last_error = tostring(load_err or 'loadfile failed') _lua_log(label .. ': load failed at ' .. candidate .. ' (' .. last_error .. ')') else last_error = tostring(chunk_or_err or 'loadfile failed') _lua_log(label .. ': load failed at ' .. candidate .. ' (' .. last_error .. ')') end end _lua_log(label .. ': load failed; candidates=' .. tostring(#candidates) .. ' last_error=' .. tostring(last_error or 'not found')) return false, nil, nil, last_error end -- Forward declaration (defined later) used by helper auto-start. local _resolve_python_exe local _refresh_store_cache local _uosc_open_list_picker local _run_pipeline_detached local _run_pipeline_background_job local _cached_store_names = {} local _store_cache_loaded = false local _store_cache_retry_pending = false -- Optional index into _cached_store_names (used by some older menu code paths). -- If unset, callers should fall back to reading SELECTED_STORE_PROP. local _selected_store_index = nil local SELECTED_STORE_PROP = 'user-data/medeia-selected-store' local STORE_PICKER_MENU_TYPE = 'medeia_store_picker' local _selected_store_loaded = false local _current_url_for_web_actions local _store_status_hint_for_url local _refresh_current_store_url_status local _skip_next_store_check_url = '' local function _normalize_store_name(store) store = trim(tostring(store or '')) store = store:gsub('^"', ''):gsub('"$', '') return trim(store) end 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 = _normalize_store_name(v) 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 _normalize_store_name(v) end local function _set_selected_store(store) store = _normalize_store_name(store) 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 = _normalize_store_name(disk) 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 = '' -- Debounce helper start attempts (window in seconds) local _helper_start_debounce_ts = 0 local HELPER_START_DEBOUNCE = 2.0 -- Track ready-heartbeat freshness so stale or non-timestamp values don't mask a stopped helper local _helper_ready_last_value = '' local _helper_ready_last_seen_ts = 0 local HELPER_READY_STALE_SECONDS = 10.0 local function _is_pipeline_helper_ready() 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 _helper_ready_last_value = '' _helper_ready_last_seen_ts = 0 return false end local s = tostring(ready) if s == '' or s == '0' then _helper_ready_last_value = s _helper_ready_last_seen_ts = 0 return false end local now = mp.get_time() or 0 if s ~= _helper_ready_last_value then _helper_ready_last_value = s _helper_ready_last_seen_ts = now end -- Prefer timestamp heartbeats from modern helpers. local n = tonumber(s) if n and n > 1000000000 then local os_now = (os and os.time) and os.time() or nil if os_now then local age = os_now - n if age < 0 then age = 0 end if age <= HELPER_READY_STALE_SECONDS then return true end end end -- Fall back to the last time we observed a new value so stale data does not appear fresh. if _helper_ready_last_seen_ts > 0 and (now - _helper_ready_last_seen_ts) <= HELPER_READY_STALE_SECONDS then return true end return false end local function _helper_ready_diagnostics() local ready = mp.get_property(PIPELINE_READY_PROP) if ready == nil or ready == '' then ready = mp.get_property_native(PIPELINE_READY_PROP) end local now = mp.get_time() or 0 local age = 'n/a' if _helper_ready_last_seen_ts > 0 then age = string.format('%.2fs', math.max(0, now - _helper_ready_last_seen_ts)) end return 'ready=' .. tostring(ready or '') .. ' last_value=' .. tostring(_helper_ready_last_value or '') .. ' last_seen_age=' .. tostring(age) end local function get_mpv_ipc_path() local ipc = mp.get_property('input-ipc-server') if ipc and ipc ~= '' then return ipc end -- Fallback: fixed pipe/socket name used by MPV/mpv_ipc.py local sep = package and package.config and package.config:sub(1, 1) or '/' if sep == '\\' then return '\\\\.\\pipe\\mpv-medios-macina' end return '/tmp/mpv-medios-macina.sock' end local function ensure_mpv_ipc_server() -- `.mpv -play` (Python) controls MPV via JSON IPC. If mpv was started -- without --input-ipc-server, make sure we set one so the running instance -- can be controlled (instead of Python spawning a separate mpv). local ipc = mp.get_property('input-ipc-server') if ipc and ipc ~= '' then return true end local desired = get_mpv_ipc_path() if not desired or desired == '' then return false end local ok = pcall(mp.set_property, 'input-ipc-server', desired) if not ok then return false end local now = mp.get_property('input-ipc-server') return (now and now ~= '') and true or false end local function attempt_start_pipeline_helper_async(callback) -- Async version: spawn helper without blocking UI. Calls callback(success) when done. callback = callback or function() end if _is_pipeline_helper_ready() then callback(true) return end -- Debounce: don't spawn multiple helpers in quick succession local now = mp.get_time() if (now - _helper_start_debounce_ts) < HELPER_START_DEBOUNCE then _lua_log('attempt_start_pipeline_helper_async: debounced (recent attempt)') callback(false) return end _helper_start_debounce_ts = now local python = _resolve_python_exe(true) if not python or python == '' then _lua_log('attempt_start_pipeline_helper_async: no python executable available') callback(false) return end local args = { python, '-m', 'MPV.pipeline_helper', '--ipc', get_mpv_ipc_path(), '--timeout', '30' } _lua_log('attempt_start_pipeline_helper_async: spawning helper python=' .. tostring(python)) -- Spawn detached; don't wait for it here (async). local ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args, detach = true }) _lua_log('attempt_start_pipeline_helper_async: detached spawn result ' .. tostring(detail or '')) if not ok then _lua_log('attempt_start_pipeline_helper_async: detached spawn failed, retrying blocking') ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args }) _lua_log('attempt_start_pipeline_helper_async: blocking spawn result ' .. tostring(detail or '')) end if not ok then _lua_log('attempt_start_pipeline_helper_async: spawn failed final=' .. tostring(detail or _describe_subprocess_result(result))) callback(false) return end -- Wait for helper to become ready in background (non-blocking). local deadline = mp.get_time() + 3.0 local timer timer = mp.add_periodic_timer(0.1, function() if _is_pipeline_helper_ready() then timer:kill() _lua_log('attempt_start_pipeline_helper_async: helper ready') callback(true) return end if mp.get_time() >= deadline then timer:kill() _lua_log('attempt_start_pipeline_helper_async: timeout waiting for ready') callback(false) end end) end local function ensure_pipeline_helper_running() -- Check if helper is already running (don't spawn from here). -- Auto-start is handled via explicit menu action only. return _is_pipeline_helper_ready() end local _ipc_async_busy = false local _ipc_async_queue = {} local function _run_helper_request_async(req, timeout_seconds, cb) cb = cb or function() end if type(req) ~= 'table' then _lua_log('ipc-async: invalid request') cb(nil, 'invalid request') return end -- Assign id and label early for logging local id = tostring(req.id or '') if id == '' then id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) req.id = id end local label = '' if req.op then label = 'op=' .. tostring(req.op) elseif req.pipeline then label = 'cmd=' .. tostring(req.pipeline) else label = '(unknown)' end _lua_log('ipc-async: queuing request id=' .. id .. ' ' .. label .. ' (busy=' .. tostring(_ipc_async_busy) .. ', queue_size=' .. tostring(#_ipc_async_queue) .. ')') if _ipc_async_busy then _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb } return end _ipc_async_busy = true local function done(resp, err) local err_text = err and tostring(err) or '' local quiet = type(req) == 'table' and req.quiet and true or false local is_timeout = err_text:find('timeout waiting response', 1, true) ~= nil local retry_count = type(req) == 'table' and tonumber(req._retry or 0) or 0 local op_name = type(req) == 'table' and tostring(req.op or '') or '' local is_retryable = is_timeout and type(req) == 'table' and retry_count < 1 and (op_name == 'ytdlp-formats' or op_name == 'run-background') if is_retryable then req._retry = retry_count + 1 req.id = nil _ipc_async_busy = false _lua_log('ipc-async: timeout on ' .. tostring(op_name) .. '; restarting helper and retrying (attempt ' .. tostring(req._retry) .. ')') pcall(mp.set_property, PIPELINE_READY_PROP, '') attempt_start_pipeline_helper_async(function(success) if success then _lua_log('ipc-async: helper restart succeeded; retrying ' .. tostring(op_name)) else _lua_log('ipc-async: helper restart failed; retrying anyway') end end) mp.add_timeout(0.3, function() _run_helper_request_async(req, timeout_seconds, cb) end) return end if err then if quiet then _lua_log('ipc-async: done id=' .. tostring(id) .. ' unavailable ' .. tostring(label)) else _lua_log('ipc-async: done id=' .. tostring(id) .. ' ERROR: ' .. tostring(err)) end else _lua_log('ipc-async: done id=' .. tostring(id) .. ' success=' .. tostring(resp and resp.success)) end _ipc_async_busy = false cb(resp, err) if #_ipc_async_queue > 0 then local next_job = table.remove(_ipc_async_queue, 1) -- Schedule next job slightly later to let mpv deliver any pending events. mp.add_timeout(0.01, function() _run_helper_request_async(next_job.req, next_job.timeout, next_job.cb) end) end end if type(req) ~= 'table' then done(nil, 'invalid request') return end ensure_mpv_ipc_server() local function send_request_payload() _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label) local req_json = utils.format_json(req) _last_ipc_last_req_json = req_json mp.set_property(PIPELINE_RESP_PROP, '') mp.set_property(PIPELINE_REQ_PROP, req_json) local deadline = mp.get_time() + (timeout_seconds or 5) local poll_timer poll_timer = mp.add_periodic_timer(0.05, function() if mp.get_time() >= deadline then poll_timer:kill() done(nil, 'timeout waiting response (' .. label .. ')') return end local resp_json = mp.get_property(PIPELINE_RESP_PROP) if resp_json and resp_json ~= '' then _last_ipc_last_resp_json = resp_json local ok, resp = pcall(utils.parse_json, resp_json) if ok and resp and resp.id == id then poll_timer:kill() _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) done(resp, nil) end end end) end local function wait_for_helper_ready(timeout, on_ready) local deadline = mp.get_time() + (timeout or 3.0) local ready_timer ready_timer = mp.add_periodic_timer(0.05, function() if _is_pipeline_helper_ready() then ready_timer:kill() on_ready() return end if mp.get_time() >= deadline then ready_timer:kill() _lua_log('ipc-async: helper wait timed out ' .. _helper_ready_diagnostics()) done(nil, 'helper not ready') return end end) end local function ensure_helper_and_send() if _is_pipeline_helper_ready() then wait_for_helper_ready(3.0, send_request_payload) return end _lua_log('ipc-async: helper not ready, auto-starting before request id=' .. id) attempt_start_pipeline_helper_async(function(success) if not success then _lua_log('ipc-async: helper auto-start failed while handling request id=' .. id) else _lua_log('ipc-async: helper auto-start triggered by request id=' .. id) end end) local helper_deadline = mp.get_time() + 6.0 local helper_timer helper_timer = mp.add_periodic_timer(0.1, function() if _is_pipeline_helper_ready() then helper_timer:kill() wait_for_helper_ready(3.0, send_request_payload) return end if mp.get_time() >= helper_deadline then helper_timer:kill() _lua_log('ipc-async: helper still not running after auto-start ' .. _helper_ready_diagnostics()) done(nil, 'helper not running') return end end) end ensure_helper_and_send() end local function run_pipeline_via_ipc_async(pipeline_cmd, seeds, timeout_seconds, cb) local req = { pipeline = pipeline_cmd } if seeds then req.seeds = seeds end _run_helper_request_async(req, timeout_seconds, function(resp, err) if type(cb) == 'function' then cb(resp, err) end end) end local function _url_can_direct_load(url) -- Determine if a URL is safe to load directly via mpv loadfile (vs. requiring pipeline). -- Complex streams like MPD/DASH manifests and ytdl URLs need the full pipeline. url = tostring(url or ''); local lower = url:lower() -- File paths and simple URLs are OK if lower:match('^file://') or lower:match('^file:///') then return true end if not lower:match('^https?://') and not lower:match('^rtmp') then return true end -- Block ytdl and other complex streams if lower:match('youtube%.com') or lower:match('youtu%.be') then return false end if lower:match('%.mpd%b()') or lower:match('%.mpd$') then return false end -- DASH manifest if lower:match('manifest%.json') then return false end if lower:match('twitch%.tv') or lower:match('youtube') then return false end if lower:match('soundcloud%.com') or lower:match('bandcamp%.com') then return false end if lower:match('spotify') or lower:match('tidal') then return false end if lower:match('reddit%.com') or lower:match('tiktok%.com') then return false end if lower:match('vimeo%.com') or lower:match('dailymotion%.com') then return false end -- Default: assume direct load is OK for plain HTTP(S) URLs return true end local function _try_direct_loadfile(url, force) -- Attempt to load URL directly via mpv without pipeline. -- Returns (success: bool, loaded: bool) where: -- - success=true, loaded=true: URL loaded successfully -- - success=true, loaded=false: URL not suitable for direct load (when not forced) -- - success=false: loadfile command failed force = force and true or false if not force and not _url_can_direct_load(url) then _lua_log('_try_direct_loadfile: URL not suitable for direct load: ' .. url) return true, false -- Not suitable, but not an error end _lua_log('_try_direct_loadfile: attempting loadfile for ' .. url) local ok_load = pcall(mp.commandv, 'loadfile', url, 'replace') _lua_log('_try_direct_loadfile: loadfile result ok_load=' .. tostring(ok_load)) return ok_load, ok_load -- Fallback attempted end local function quote_pipeline_arg(s) -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing. s = tostring(s or '') s = s:gsub('\\', '\\\\'):gsub('"', '\\"') return '"' .. s .. '"' end local function _is_windows() local sep = package and package.config and package.config:sub(1, 1) or '/' return sep == '\\' end _resolve_python_exe = function(prefer_no_console) local configured = trim(tostring((opts and opts.python_path) or 'python')) local candidates = _build_python_candidates(configured, prefer_no_console) for _, candidate in ipairs(candidates) do if candidate:match('[/\\]') then if _path_exists(candidate) then return candidate end else return candidate end end return configured ~= '' and configured or 'python' end local function _extract_target_from_memory_uri(text) if type(text) ~= 'string' then return nil end if not text:match('^memory://') then return nil end for line in text:gmatch('[^\r\n]+') do line = trim(line) if line ~= '' and not line:match('^#') and not line:match('^memory://') then return line end end return nil end local function _percent_decode(s) if type(s) ~= 'string' then return s end return (s:gsub('%%(%x%x)', function(hex) return string.char(tonumber(hex, 16)) end)) end local function _extract_query_param(url, key) if type(url) ~= 'string' then return nil end key = tostring(key or '') if key == '' then return nil end local pattern = '[?&]' .. key:gsub('([^%w])', '%%%1') .. '=([^&#]+)' local v = url:match(pattern) if v then return _percent_decode(v) end return nil end local function _download_url_for_current_item(url) url = trim(tostring(url or '')) if url == '' then return '', false end local base, query = url:match('^([^?]+)%?(.*)$') if not base or not query or query == '' then return url, false end local base_lower = tostring(base or ''):lower() local has_explicit_video = false if base_lower:match('youtu%.be/') then has_explicit_video = true elseif base_lower:match('youtube%.com/watch') or base_lower:match('youtube%-nocookie%.com/watch') then has_explicit_video = _extract_query_param(url, 'v') ~= nil end if not has_explicit_video then return url, false end local kept = {} local changed = false for pair in query:gmatch('[^&]+') do local raw_key = pair:match('^([^=]+)') or pair local key = tostring(_percent_decode(raw_key) or raw_key or ''):lower() local keep = true if key == 'list' or key == 'index' or key == 'start_radio' or key == 'pp' or key == 'si' then keep = false changed = true end if keep then kept[#kept + 1] = pair end end if not changed then return url, false end if #kept > 0 then return base .. '?' .. table.concat(kept, '&'), true end return base, true end local function _normalize_url_for_store_lookup(url) url = trim(tostring(url or '')) if url == '' then return '' end url = url:gsub('#.*$', '') local base, query = url:match('^([^?]+)%?(.*)$') if base then local kept = {} for pair in query:gmatch('[^&]+') do local raw_key = pair:match('^([^=]+)') or pair local key = tostring(_percent_decode(raw_key) or raw_key or ''):lower() local keep = true if key == 't' or key == 'start' or key == 'time_continue' or key == 'timestamp' or key == 'time' or key == 'begin' then keep = false elseif key:match('^utm_') then keep = false end if keep then kept[#kept + 1] = pair end end if #kept > 0 then url = base .. '?' .. table.concat(kept, '&') else url = base end end url = url:gsub('^[%a][%w+%.%-]*://', '') url = url:gsub('^www%.', '') url = url:gsub('/+$', '') return url:lower() end local function _build_store_lookup_needles(url) local out = {} local seen = {} local function add(value) value = trim(tostring(value or '')) if value == '' then return end local key = value:lower() if seen[key] then return end seen[key] = true out[#out + 1] = value end local raw = trim(tostring(url or '')) add(raw) local without_fragment = raw:gsub('#.*$', '') add(without_fragment) local normalized = _normalize_url_for_store_lookup(raw) add(normalized) local schemeless = without_fragment:gsub('^[%a][%w+%.%-]*://', '') schemeless = schemeless:gsub('/+$', '') add(schemeless) add(schemeless:gsub('^www%.', '')) return out end local function _check_store_for_existing_url(store, url, cb) cb = cb or function() end url = trim(tostring(url or '')) if url == '' then cb(nil, 'missing url') return end local needles = _build_store_lookup_needles(url) local idx = 1 local function run_next(last_err) if idx > #needles then cb(nil, last_err) return end local needle = tostring(needles[idx]) idx = idx + 1 local query = 'url:' .. needle _lua_log('store-check: probing global query=' .. tostring(query)) _run_helper_request_async({ op = 'url-exists', data = { url = url, needles = { needle } }, quiet = true }, 5.0, function(resp, err) if resp and resp.success then local data = resp.data if type(data) ~= 'table' or #data == 0 then run_next(nil) return end cb(data, nil, needle) return end local details = trim(tostring(err or '')) if details == '' and type(resp) == 'table' then if resp.error and tostring(resp.error) ~= '' then details = trim(tostring(resp.error)) elseif resp.stderr and tostring(resp.stderr) ~= '' then details = trim(tostring(resp.stderr)) end end run_next(details ~= '' and details or nil) end) end run_next(nil) end local function _current_target() local path = mp.get_property('path') if not path or path == '' then return nil end local mem = _extract_target_from_memory_uri(path) if mem and mem ~= '' then return mem end return path end local ImageControl = { enabled = false, binding_names = {}, pan_step = 0.05, pan_step_slow = 0.02, zoom_step = 0.45, zoom_step_slow = 0.15, } local MAX_IMAGE_ZOOM = 4.5 local function _install_q_block() pcall(mp.commandv, 'keybind', 'q', 'script-message', 'medeia-image-quit-block') end local function _restore_q_default() pcall(mp.commandv, 'keybind', 'q', 'quit') end local function _enable_image_section() pcall(mp.commandv, 'enable-section', 'image', 'allow-hide-cursor') end local function _disable_image_section() pcall(mp.commandv, 'disable-section', 'image') end mp.register_script_message('medeia-image-quit-block', function() if ImageControl.enabled then mp.osd_message('Press ESC if you really want to quit', 0.7) return end mp.commandv('quit') end) local ImageExtensions = { jpg = true, jpeg = true, png = true, gif = true, webp = true, bmp = true, tif = true, tiff = true, heic = true, heif = true, avif = true, ico = true, } local function _clean_path_for_extension(path) if type(path) ~= 'string' then return nil end local clean = path:match('([^?]+)') or path clean = clean:match('([^#]+)') or clean local last = clean:match('([^/\\]+)$') or '' local ext = last:match('%.([A-Za-z0-9]+)$') if not ext then return nil end return ext:lower() end local function _is_image_path(path) local ext = _clean_path_for_extension(path) return ext and ImageExtensions[ext] end local function _get_current_item_is_image() local video_info = mp.get_property_native('current-tracks/video') if type(video_info) == 'table' then if video_info.image == true then return true end if video_info.image == false then return false end end local target = _current_target() if target then return _is_image_path(target) end return false end local function _set_image_property(value) pcall(mp.set_property_native, 'user-data/mpv/image', value and true or false) end local function _show_image_status(message) local zoom = mp.get_property_number('video-zoom') or 0 local pan_x = mp.get_property_number('video-pan-x') or 0 local pan_y = mp.get_property_number('video-pan-y') or 0 local zoom_percent = math.floor((1 + zoom) * 100 + 0.5) local text = string.format('Image: zoom %d%% pan %+.2f %+.2f', zoom_percent, pan_x, pan_y) if message and message ~= '' then text = message .. ' | ' .. text end mp.osd_message(text, 0.7) end local function _change_pan(dx, dy) local pan_x = mp.get_property_number('video-pan-x') or 0 local pan_y = mp.get_property_number('video-pan-y') or 0 mp.set_property_number('video-pan-x', pan_x + dx) mp.set_property_number('video-pan-y', pan_y + dy) _show_image_status() end local function _change_zoom(delta) local current = mp.get_property_number('video-zoom') or 0 local target = current + delta if target > MAX_IMAGE_ZOOM then target = MAX_IMAGE_ZOOM end if target < -1.0 then target = -1.0 end mp.set_property_number('video-zoom', target) mp.set_property('video-unscaled', 'no') if target >= MAX_IMAGE_ZOOM then mp.osd_message('Image zoom maxed at 450%', 0.7) else _show_image_status() end end local function _reset_pan_zoom() mp.set_property_number('video-pan-x', 0) mp.set_property_number('video-pan-y', 0) mp.set_property_number('video-zoom', 0) mp.set_property('video-align-x', '0') mp.set_property('video-align-y', '0') mp.set_property('panscan', 0) mp.set_property('video-unscaled', 'no') _show_image_status('Zoom reset') end local function _sanitize_filename_component(s) s = trim(tostring(s or '')) if s == '' then return 'screenshot' end -- Windows-unfriendly characters: <>:"/\|?* and control chars s = s:gsub('[%c]', '') s = s:gsub('[<>:"/\\|%?%*]', '_') s = trim(s) s = s:gsub('[%.%s]+$', '') if s == '' then return 'screenshot' end return s end local function _strip_title_extension(title, path) title = trim(tostring(title or '')) if title == '' then return title end path = tostring(path or '') local ext = path:match('%.([%w%d]+)$') if not ext or ext == '' then return title end ext = ext:lower() local suffix = '.' .. ext if title:lower():sub(-#suffix) == suffix then return trim(title:sub(1, #title - #suffix)) end return title end local _pending_screenshot = nil local function _normalize_tag_list(value) local tags = {} local seen = {} local function add_tag(text) text = trim(tostring(text or '')) if text == '' then return end local key = text:lower() if seen[key] then return end seen[key] = true tags[#tags + 1] = text end if type(value) == 'table' then for _, item in ipairs(value) do add_tag(item) end return tags end local text = tostring(value or '') for token in text:gmatch('[^,;\r\n]+') do add_tag(token) end return tags end local function _write_repl_queue_file_local(command_text, source_text, metadata) command_text = trim(tostring(command_text or '')) if command_text == '' then return nil, 'empty pipeline command' end local repo_root = _detect_repo_root() if repo_root == '' then return nil, 'repo root not found' end local log_dir = utils.join_path(repo_root, 'Log') if not _path_exists(log_dir) then return nil, 'Log directory not found' end local stamp = tostring(math.floor(mp.get_time() * 1000)) local token = tostring(math.random(100000, 999999)) local path = utils.join_path(log_dir, 'medeia-repl-queue-' .. stamp .. '-' .. token .. '.json') local payload = { id = stamp .. '-' .. token, command = command_text, source = trim(tostring(source_text or 'external')), created_at = os.time(), } if type(metadata) == 'table' and next(metadata) ~= nil then payload.metadata = metadata end local encoded = utils.format_json(payload) if type(encoded) ~= 'string' or encoded == '' then return nil, 'failed to encode queue payload' end local fh = io.open(path, 'w') if not fh then return nil, 'failed to open queue file' end fh:write(encoded) fh:close() return path, nil end local function _queue_pipeline_in_repl(pipeline_cmd, queued_message, failure_prefix, queue_label, metadata) pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then mp.osd_message((failure_prefix or 'REPL queue failed') .. ': empty pipeline command', 5) return false end local queue_metadata = { kind = 'mpv-download' } if type(metadata) == 'table' then for key, value in pairs(metadata) do queue_metadata[key] = value end end _lua_log(queue_label .. ': queueing repl cmd=' .. pipeline_cmd) do local queue_path, queue_err = _write_repl_queue_file_local( pipeline_cmd, queue_label, queue_metadata ) if queue_path then _lua_log(queue_label .. ': queued repl command locally path=' .. tostring(queue_path)) mp.osd_message(tostring(queued_message or 'Queued in REPL'), 3) return true end _lua_log(queue_label .. ': local queue write failed err=' .. tostring(queue_err or 'unknown') .. '; falling back to helper') end ensure_mpv_ipc_server() if not ensure_pipeline_helper_running() then mp.osd_message((failure_prefix or 'REPL queue failed') .. ': helper not running', 5) return false end _run_helper_request_async( { op = 'queue-repl-command', data = { command = pipeline_cmd, source = queue_label, metadata = queue_metadata, }, }, 4.0, function(resp, err) if resp and resp.success then local queue_path = trim(tostring(resp.path or '')) _lua_log(queue_label .. ': queued repl command path=' .. tostring(queue_path)) mp.osd_message(tostring(queued_message or 'Queued in REPL'), 3) return end local err_text = tostring(err or '') if err_text:find('timeout waiting response', 1, true) ~= nil then _lua_log(queue_label .. ': queue ack timeout; assuming repl command queued') mp.osd_message(tostring(queued_message or 'Queued in REPL'), 3) return end local detail = tostring(err or (resp and resp.error) or 'unknown') _lua_log(queue_label .. ': queue failed err=' .. detail) mp.osd_message((failure_prefix or 'REPL queue failed') .. ': ' .. detail, 5) end ) return true end local function _start_screenshot_store_save(store, out_path, tags) store = _normalize_store_name(store) out_path = _normalize_fs_path(out_path) if store == '' or out_path == '' then mp.osd_message('Screenshot upload failed: invalid store or path', 5) return false end local tag_list = _normalize_tag_list(tags) local cmd = 'add-file -store ' .. quote_pipeline_arg(store) .. ' -path ' .. quote_pipeline_arg(out_path) _set_selected_store(store) local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or '' if #tag_list > 0 then local tag_string = table.concat(tag_list, ',') cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string) end _lua_log('screenshot-save: queueing repl pipeline cmd=' .. cmd) return _queue_pipeline_in_repl( cmd, 'Queued in REPL: screenshot -> ' .. store .. tag_suffix, 'Screenshot queue failed', 'screenshot-save', { kind = 'mpv-screenshot', mpv_notify = { success_text = 'Screenshot saved to store: ' .. store .. tag_suffix, failure_text = 'Screenshot upload failed', duration_ms = 3500, }, } ) end local function _commit_pending_screenshot(tags) if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path or not _pending_screenshot.store then return end local store = tostring(_pending_screenshot.store or '') local out_path = tostring(_pending_screenshot.path or '') _pending_screenshot = nil _start_screenshot_store_save(store, out_path, tags) end local function _apply_screenshot_tag_query(query) pcall(function() mp.commandv('script-message-to', 'uosc', 'close-menu', SCREENSHOT_TAG_MENU_TYPE) end) _commit_pending_screenshot(_normalize_tag_list(query)) end local function _open_screenshot_tag_prompt(store, out_path) store = _normalize_store_name(store) out_path = _normalize_fs_path(out_path) if store == '' or out_path == '' then return end _pending_screenshot = { store = store, path = out_path } if not ensure_uosc_loaded() then _commit_pending_screenshot(nil) return end local menu_data = { type = SCREENSHOT_TAG_MENU_TYPE, title = 'Screenshot tags', search_style = 'palette', search_debounce = 'submit', on_search = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-tags-search' }, footnote = 'Optional comma-separated tags. Press Enter to save, or choose Save without tags.', items = { { title = 'Save without tags', hint = 'Skip optional tags', value = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-tags-event', utils.format_json({ query = '' }) }, }, }, } mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) end local function _open_store_picker_for_pending_screenshot() if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path then return end 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 items[#items + 1] = { title = name, hint = (selected ~= '' and name == selected) and 'Current store' or '', active = (selected ~= '' and name == selected) and true or false, value = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-pick-store', utils.format_json({ store = name }) }, } end end else items[#items + 1] = { title = 'No stores found', hint = 'Configure stores in config.conf', selectable = false, } end return items end _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, 'Save screenshot', build_items()) mp.add_timeout(0.05, function() if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path then return end _refresh_store_cache(1.5, function(success, changed) if success and changed then _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, 'Save screenshot', build_items()) end end) end) end local function _capture_screenshot() local function _format_time_label(seconds) local total = math.max(0, math.floor(tonumber(seconds or 0) or 0)) local hours = math.floor(total / 3600) local minutes = math.floor(total / 60) % 60 local secs = total % 60 local parts = {} if hours > 0 then table.insert(parts, ('%dh'):format(hours)) end if minutes > 0 or hours > 0 then table.insert(parts, ('%dm'):format(minutes)) end table.insert(parts, ('%ds'):format(secs)) return table.concat(parts) end local time = mp.get_property_number('time-pos') or mp.get_property_number('time') or 0 local label = _format_time_label(time) local raw_title = trim(tostring(mp.get_property('media-title') or '')) local raw_path = tostring(mp.get_property('path') or '') if raw_title == '' then raw_title = 'screenshot' end raw_title = _strip_title_extension(raw_title, raw_path) local safe_title = _sanitize_filename_component(raw_title) local filename = safe_title .. '_' .. label .. '.png' local temp_dir = _normalize_fs_path(mp.get_property('user-data/medeia-config-temp')) if temp_dir == '' then temp_dir = _normalize_fs_path(os.getenv('TEMP') or os.getenv('TMP') or '/tmp') end local out_path = utils.join_path(temp_dir, filename) out_path = _normalize_fs_path(out_path) local function do_screenshot(mode) mode = mode or 'video' local ok, err = pcall(function() return mp.commandv('screenshot-to-file', out_path, mode) end) return ok, err end -- Try 'video' first (no OSD). If that fails (e.g. audio mode without video/art), -- try 'window' as fallback. local ok = do_screenshot('video') if not ok then _lua_log('screenshot: video-mode failed; trying window-mode') ok = do_screenshot('window') end if not ok then _lua_log('screenshot: BOTH video and window modes FAILED') mp.osd_message('Screenshot failed (no frames)', 2) return end _ensure_selected_store_loaded() local function dispatch_screenshot_save() local store_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 local selected_store = _normalize_store_name(_get_selected_store()) if store_count > 1 then _pending_screenshot = { path = out_path } _open_store_picker_for_pending_screenshot() return end if selected_store == '' and store_count == 1 then selected_store = _normalize_store_name(_cached_store_names[1]) end if selected_store == '' then mp.osd_message('Select a store first (Store button)', 2) return end _open_screenshot_tag_prompt(selected_store, out_path) end if not _store_cache_loaded then _refresh_store_cache(1.5, function() dispatch_screenshot_save() end) else dispatch_screenshot_save() end end mp.register_script_message('medeia-image-screenshot', function() _capture_screenshot() end) mp.register_script_message('medeia-image-screenshot-pick-store', function(json) if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path then return end local ok, ev = pcall(utils.parse_json, json) if not ok or type(ev) ~= 'table' then return end local store = _normalize_store_name(ev.store) if store == '' then return end local out_path = tostring(_pending_screenshot.path or '') _open_screenshot_tag_prompt(store, out_path) end) mp.register_script_message('medeia-image-screenshot-tags-search', function(query) _apply_screenshot_tag_query(query) end) mp.register_script_message('medeia-image-screenshot-tags-event', function(json) local ok, ev = pcall(utils.parse_json, json) if not ok or type(ev) ~= 'table' then return end _apply_screenshot_tag_query(ev.query) end) local CLIP_MARKER_SLOT_COUNT = 2 local clip_markers = {} local initial_chapters = nil local function _format_clip_marker_label(time) if type(time) ~= 'number' then return '0s' end local total = math.max(0, math.floor(time)) local hours = math.floor(total / 3600) local minutes = math.floor(total / 60) % 60 local seconds = total % 60 local parts = {} if hours > 0 then table.insert(parts, ('%dh'):format(hours)) end if minutes > 0 or hours > 0 then table.insert(parts, ('%dm'):format(minutes)) end table.insert(parts, ('%ds'):format(seconds)) return table.concat(parts) end local function _apply_clip_chapters() local chapters = {} if initial_chapters then for _, chapter in ipairs(initial_chapters) do table.insert(chapters, chapter) end end for idx = 1, CLIP_MARKER_SLOT_COUNT do local time = clip_markers[idx] if time and type(time) == 'number' then table.insert(chapters, { time = time, title = _format_clip_marker_label(time), }) end end table.sort(chapters, function(a, b) return (a.time or 0) < (b.time or 0) end) mp.set_property_native('chapter-list', chapters) end local function _reset_clip_markers() for idx = 1, CLIP_MARKER_SLOT_COUNT do clip_markers[idx] = nil end _apply_clip_chapters() end local function _capture_clip() local time = mp.get_property_number('time-pos') or mp.get_property_number('time') if not time then mp.osd_message('Cannot capture clip; no time available', 0.7) return end local slot = nil for idx = 1, CLIP_MARKER_SLOT_COUNT do if not clip_markers[idx] then slot = idx break end end if not slot then local best = math.huge for idx = 1, CLIP_MARKER_SLOT_COUNT do local existing = clip_markers[idx] local distance = math.abs((existing or 0) - time) if distance < best then best = distance slot = idx end end slot = slot or 1 end clip_markers[slot] = time _apply_clip_chapters() mp.commandv('screenshot-to-file', ('clip-%s-%.0f.png'):format(os.date('%Y%m%d-%H%M%S'), time)) local label = _format_clip_marker_label(time) mp.osd_message(('Clip marker %d set at %s'):format(slot, label), 0.7) end mp.register_event('file-loaded', function() initial_chapters = mp.get_property_native('chapter-list') or {} _reset_clip_markers() end) mp.register_script_message('medeia-image-clip', function() _capture_clip() end) local function _get_trim_range_from_clip_markers() local times = {} for idx = 1, CLIP_MARKER_SLOT_COUNT do local t = clip_markers[idx] if type(t) == 'number' then table.insert(times, t) end end table.sort(times, function(a, b) return a < b end) if #times < 2 then return nil end local start_t = times[1] local end_t = times[2] if type(start_t) ~= 'number' or type(end_t) ~= 'number' then return nil end if end_t <= start_t then return nil end return _format_clip_marker_label(start_t) .. '-' .. _format_clip_marker_label(end_t) end local function _audio_only() mp.commandv('set', 'vid', 'no') mp.osd_message('Audio-only playback enabled', 1) end mp.register_script_message('medeia-audio-only', function() _audio_only() end) local function _cancel_sleep_timer(show_message) if _sleep_timer ~= nil then pcall(function() _sleep_timer:kill() end) _sleep_timer = nil end if show_message then mp.osd_message('Sleep timer cancelled', 1.5) end end local function _parse_sleep_minutes(text) local s = trim(tostring(text or '')):lower() if s == '' then return nil end if s == 'off' or s == 'cancel' or s == 'stop' or s == '0' then return 0 end local hours = s:match('^([%d%.]+)%s*h$') if hours then local value = tonumber(hours) if value and value > 0 then return value * 60 end return nil end local mins = s:match('^([%d%.]+)%s*m$') if mins then local value = tonumber(mins) if value and value >= 0 then return value end return nil end local value = tonumber(s) if value and value >= 0 then return value end return nil end local function _open_sleep_timer_prompt() local items = { { title = '15 minutes', hint = 'Quick preset', value = { 'script-message-to', mp.get_script_name(), 'medeia-sleep-timer-event', utils.format_json({ type = 'search', query = '15' }) }, }, { title = '30 minutes', hint = 'Quick preset', value = { 'script-message-to', mp.get_script_name(), 'medeia-sleep-timer-event', utils.format_json({ type = 'search', query = '30' }) }, }, { title = '60 minutes', hint = 'Quick preset', value = { 'script-message-to', mp.get_script_name(), 'medeia-sleep-timer-event', utils.format_json({ type = 'search', query = '60' }) }, }, { title = 'Cancel timer', hint = 'Also accepts off / 0 / cancel', value = { 'script-message-to', mp.get_script_name(), 'medeia-sleep-timer-event', utils.format_json({ type = 'search', query = '0' }) }, }, } local menu_data = { type = SLEEP_PROMPT_MENU_TYPE, title = 'Sleep Timer', search_style = 'palette', search_debounce = 'submit', on_search = { 'script-message-to', mp.get_script_name(), 'medeia-sleep-timer-search' }, footnote = 'Enter minutes (30), or use 1h / 1.5h. Enter 0 to cancel.', items = items, } if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) else mp.osd_message('Sleep timer unavailable (uosc not loaded)', 2.0) end end local function _apply_sleep_timer_query(query) local minutes = _parse_sleep_minutes(query) if minutes == nil then mp.osd_message('Sleep timer: enter minutes, 1h, or 0 to cancel', 2.0) return end if minutes <= 0 then _cancel_sleep_timer(true) pcall(function() mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE) end) return end _cancel_sleep_timer(false) local seconds = math.max(1, math.floor(minutes * 60)) _sleep_timer = mp.add_timeout(seconds, function() _sleep_timer = nil mp.osd_message('Sleep timer: closing mpv', 1.5) mp.commandv('quit') end) mp.osd_message(string.format('Sleep timer set: %d min', math.floor(minutes + 0.5)), 1.5) _lua_log('sleep: timer set minutes=' .. tostring(minutes) .. ' seconds=' .. tostring(seconds)) pcall(function() mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE) end) end local function _handle_sleep_timer_event(json) local ok, ev = pcall(utils.parse_json, json) if not ok or type(ev) ~= 'table' then _lua_log('sleep: invalid event payload=' .. tostring(json)) return end if ev.type ~= 'search' then return end _apply_sleep_timer_query(ev.query) end mp.register_script_message('medeia-sleep-timer', function() _open_sleep_timer_prompt() end) mp.register_script_message('medeia-sleep-timer-event', function(json) _handle_sleep_timer_event(json) end) mp.register_script_message('medeia-sleep-timer-search', function(query) _apply_sleep_timer_query(query) end) local function _bind_image_key(key, name, fn, opts) opts = opts or {} if ImageControl.binding_names[name] then pcall(mp.remove_key_binding, name) ImageControl.binding_names[name] = nil end local ok, err = pcall(mp.add_forced_key_binding, key, name, fn, opts) if ok then ImageControl.binding_names[name] = true else mp.msg.warn('Failed to add image binding ' .. tostring(key) .. ': ' .. tostring(err)) end end local function _unbind_image_keys() for name in pairs(ImageControl.binding_names) do pcall(mp.remove_key_binding, name) ImageControl.binding_names[name] = nil end end local function _activate_image_controls() if ImageControl.enabled then return end ImageControl.enabled = true _set_image_property(true) _enable_image_section() mp.osd_message('Image viewer controls enabled', 1.2) _bind_image_key('LEFT', 'image-pan-left', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('RIGHT', 'image-pan-right', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true}) _bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('Shift+RIGHT', 'image-pan-right-fine', function() _change_pan(ImageControl.pan_step_slow, 0) end, {repeatable=true}) _bind_image_key('Shift+UP', 'image-pan-up-fine', function() _change_pan(0, -ImageControl.pan_step_slow) end, {repeatable=true}) _bind_image_key('Shift+DOWN', 'image-pan-down-fine', function() _change_pan(0, ImageControl.pan_step_slow) end, {repeatable=true}) _bind_image_key('h', 'image-pan-h', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('l', 'image-pan-l', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('j', 'image-pan-j', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true}) _bind_image_key('k', 'image-pan-k', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true}) _bind_image_key('w', 'image-pan-w', function() _change_pan(0, -ImageControl.pan_step) end, {repeatable=true}) _bind_image_key('s', 'image-pan-s', function() _change_pan(0, ImageControl.pan_step) end, {repeatable=true}) _bind_image_key('a', 'image-pan-a', function() _change_pan(ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('d', 'image-pan-d', function() _change_pan(-ImageControl.pan_step, 0) end, {repeatable=true}) _bind_image_key('=', 'image-zoom-in', function() _change_zoom(ImageControl.zoom_step) end, {repeatable=true}) _disable_image_section() _bind_image_key('-', 'image-zoom-out', function() _change_zoom(-ImageControl.zoom_step) end, {repeatable=true}) _bind_image_key('+', 'image-zoom-in-fine', function() _change_zoom(ImageControl.zoom_step_slow) end, {repeatable=true}) _bind_image_key('_', 'image-zoom-out-fine', function() _change_zoom(-ImageControl.zoom_step_slow) end, {repeatable=true}) _bind_image_key('0', 'image-zoom-reset', _reset_pan_zoom) _bind_image_key('Space', 'image-status', function() _show_image_status('Image status') end) _bind_image_key('f', 'image-screenshot', _capture_screenshot) _install_q_block() end local function _deactivate_image_controls() if not ImageControl.enabled then return end ImageControl.enabled = false _set_image_property(false) _restore_q_default() _unbind_image_keys() mp.osd_message('Image viewer controls disabled', 1.0) mp.set_property('panscan', 0) mp.set_property('video-zoom', 0) mp.set_property_number('video-pan-x', 0) mp.set_property_number('video-pan-y', 0) mp.set_property('video-align-x', '0') mp.set_property('video-align-y', '0') end local function _update_image_mode() local should_image = _get_current_item_is_image() if should_image then _activate_image_controls() else _deactivate_image_controls() end end mp.register_event('file-loaded', function() _update_image_mode() end) mp.register_event('shutdown', function() _restore_q_default() end) _update_image_mode() local function _extract_store_hash(target) if type(target) ~= 'string' or target == '' then return nil end local hash = _extract_query_param(target, 'hash') local store = _extract_query_param(target, 'store') if hash and store then local h = tostring(hash):lower() if h:match('^[0-9a-f]+$') and #h == 64 then return { store = tostring(store), hash = h } end end return nil end local function _pick_folder_windows() -- Native folder picker via PowerShell + WinForms. local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]] local res = utils.subprocess({ -- Hide the PowerShell console window (dialog still shows). args = { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps }, cancellable = false, }) if res and res.status == 0 and res.stdout then local out = trim(tostring(res.stdout)) if out ~= '' then return out end end return nil end local function _store_names_key(names) if type(names) ~= 'table' or #names == 0 then return '' end local normalized = {} for _, name in ipairs(names) do normalized[#normalized + 1] = trim(tostring(name or '')) end return table.concat(normalized, '\0') end local function _run_pipeline_request_async(pipeline_cmd, seeds, timeout_seconds, cb) cb = cb or function() end pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then cb(nil, 'empty pipeline command') return end ensure_mpv_ipc_server() local req = { pipeline = pipeline_cmd } if seeds then req.seeds = seeds end _run_helper_request_async(req, timeout_seconds or 30, cb) end _refresh_store_cache = function(timeout_seconds, on_complete) ensure_mpv_ipc_server() local prev_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 local prev_key = _store_names_key(_cached_store_names) 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 local function handle_cached(resp) if not resp or type(resp) ~= 'table' or type(resp.choices) ~= 'table' then _lua_log('stores: cache_parse result missing choices table; resp_type=' .. tostring(type(resp))) return false end local out = {} for _, v in ipairs(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 #out > 0 then preview = table.concat(out, ', ') end _lua_log('stores: loaded ' .. tostring(#out) .. ' stores from cache: ' .. tostring(preview)) if type(on_complete) == 'function' then on_complete(true, _store_names_key(out) ~= prev_key) end return true end local ok, cached_resp = pcall(utils.parse_json, cached_json) _lua_log('stores: cache_parse ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp))) if ok then 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 if ok then if handle_cached(cached_resp) then return true end 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 if not _is_pipeline_helper_ready() then if not _store_cache_retry_pending then _store_cache_retry_pending = true _lua_log('stores: helper not ready; deferring dynamic store refresh') attempt_start_pipeline_helper_async(function(success) _lua_log('stores: deferred helper start success=' .. tostring(success)) end) mp.add_timeout(1.0, function() _store_cache_retry_pending = false _refresh_store_cache(timeout_seconds, on_complete) end) else _lua_log('stores: helper not ready; refresh already deferred') end return false end _lua_log('stores: requesting store-choices via helper (fallback)') _run_helper_request_async({ op = 'store-choices' }, math.max(timeout_seconds or 0, 6.0), function(resp, err) local success = false local changed = false if resp and resp.success and type(resp.choices) == 'table' then local out = {} for _, v in ipairs(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 #out > 0 then preview = table.concat(out, ', ') end _lua_log('stores: loaded ' .. tostring(#out) .. ' stores via helper request: ' .. tostring(preview)) success = true changed = (#out ~= prev_count) or (_store_names_key(out) ~= prev_key) else _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 err or '') ) end if type(on_complete) == 'function' then on_complete(success, changed) end end) return false end _uosc_open_list_picker = function(menu_type, title, items) local menu_data = { type = menu_type, title = title, items = items or {}, } if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) else _lua_log('menu: uosc not available; cannot open-menu') 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 current_url = trim(tostring(_current_url_for_web_actions() or _current_target() or '')) 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 } local hint = _store_status_hint_for_url(name, current_url, nil) if (not hint or hint == '') and selected ~= '' and name == selected then hint = 'Current store' end items[#items + 1] = { title = name, hint = hint, 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) _refresh_store_cache(1.2, function(success, changed) if success and changed then _lua_log('stores: reopening menu (store list changed)') _uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items()) end 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) _refresh_current_store_url_status('store-select') end) -- No-op handler for placeholder menu items. mp.register_script_message('medios-nop', function() return end) local _pending_download = nil local _pending_format_change = nil -- Per-file state (class-like) for format caching. local FileState = {} FileState.__index = FileState function FileState.new() return setmetatable({ url = nil, formats = nil, }, FileState) end function FileState:set_url(url) local normalized = trim(tostring(url or '')) if normalized == '' then self.url = nil self.formats = nil self.formats_table = nil return end if self.url ~= normalized then self.url = normalized self.formats = nil self.formats_table = nil end end function FileState:has_formats() return type(self.formats) == 'table' and type(self.formats.rows) == 'table' and #self.formats.rows > 0 end function FileState:set_formats(url, tbl) self:set_url(url) self.formats = tbl self.formats_table = tbl end M.file = M.file or FileState.new() -- Cache yt-dlp format lists per URL so Change Format is instant. local _formats_cache = {} local _formats_inflight = {} local _formats_waiters = {} local _formats_prefetch_retries = {} local _format_cache_poll_generation = 0 local _last_raw_format_summary = '' local _get_cached_formats_table local _debug_dump_formatted_formats local _show_format_list_osd local _ipc_async_busy = false local _ipc_async_queue = {} local function _is_http_url(u) if type(u) ~= 'string' then return false end return u:match('^https?://') ~= nil end local _ytdlp_domains_cached = nil local function _is_ytdlp_url(u) if not u or type(u) ~= 'string' then return false end local low = trim(u:lower()) if not low:match('^https?://') then return false end -- Fast exclusions for things we know are not meant for yt-dlp format switching if low:find('/get_files/file', 1, true) then return false end if low:find('tidal.com/manifest', 1, true) then return false end if low:find('alldebrid.com/f/', 1, true) then return false end -- Try to use the cached domain list from the pipeline helper local domains_str = mp.get_property('user-data/medeia-ytdlp-domains-cached') or '' if domains_str ~= '' then if not _ytdlp_domains_cached then _ytdlp_domains_cached = {} for d in domains_str:gmatch('%S+') do _ytdlp_domains_cached[d] = true end end local host = low:match('^https?://([^/]+)') if host then -- Remove port if present host = host:match('^([^:]+)') -- Check direct match or parent domain matches local parts = {} for p in host:gmatch('[^%.]+') do table.insert(parts, p) end -- Check from full domain down to top-level (e.g. m.youtube.com, youtube.com) for i = 1, #parts - 1 do local candidate = table.concat(parts, '.', i) if _ytdlp_domains_cached[candidate] then return true end end end end -- Fallback/Hardcoded: Probable video/audio sites for which Change Format is actually useful local patterns = { 'youtube%.com', 'youtu%.be', 'vimeo%.com', 'twitch%.tv', 'soundcloud%.com', 'bandcamp%.com', 'bilibili%.com', 'dailymotion%.com', 'pixiv%.net', 'twitter%.com', 'x%.com', 'instagram%.com', 'tiktok%.com', 'reddit%.com', 'facebook%.com', 'fb%.watch' } for _, p in ipairs(patterns) do if low:match(p) then return true end end -- If we have formats already cached for this URL, it's definitely supported if _get_cached_formats_table(u) then return true end return false end local function _set_current_web_url(url) local normalized = trim(tostring(url or '')) if normalized ~= '' and _is_http_url(normalized) then pcall(mp.set_property, CURRENT_WEB_URL_PROP, normalized) if type(M.file) == 'table' and M.file.set_url then M.file:set_url(normalized) end return normalized end pcall(mp.set_property, CURRENT_WEB_URL_PROP, '') if type(M.file) == 'table' and M.file.set_url then M.file:set_url(nil) end return nil end local function _get_current_web_url() local current = trim(tostring(mp.get_property(CURRENT_WEB_URL_PROP) or '')) if current ~= '' and _is_http_url(current) then return current end return nil end _current_url_for_web_actions = function() local current = _get_current_web_url() if current and current ~= '' then return current end local target = _current_target() if not target or target == '' then return nil end return tostring(target) end local function _sync_current_web_url_from_playback() local target = _current_target() local target_str = trim(tostring(target or '')) if target_str ~= '' and _extract_store_hash(target_str) then _set_current_web_url(nil) return end if target_str ~= '' and _is_http_url(target_str) and _is_ytdlp_url(target_str) then _set_current_web_url(target_str) return end local current = _get_current_web_url() if current and current ~= '' then local raw = mp.get_property_native('ytdl-raw-info') if type(raw) == 'table' then if type(M.file) == 'table' and M.file.set_url then M.file:set_url(current) end return end end if target_str == '' or not _is_http_url(target_str) then _set_current_web_url(nil) end end local _current_store_url_status = { generation = 0, store = '', url = '', status = 'idle', err = '', match_count = 0, needle = '', } local function _set_current_store_url_status(store, url, status, err, match_count, needle) _current_store_url_status.store = trim(tostring(store or '')) _current_store_url_status.url = trim(tostring(url or '')) _current_store_url_status.status = trim(tostring(status or 'idle')) _current_store_url_status.err = trim(tostring(err or '')) _current_store_url_status.match_count = tonumber(match_count or 0) or 0 _current_store_url_status.needle = trim(tostring(needle or '')) end local function _begin_current_store_url_status(store, url, status, err, match_count, needle) _current_store_url_status.generation = (_current_store_url_status.generation or 0) + 1 _set_current_store_url_status(store, url, status, err, match_count, needle) return _current_store_url_status.generation end local function _current_store_url_status_matches(store, url) local current_url = _normalize_url_for_store_lookup(_current_store_url_status.url) local target_url = _normalize_url_for_store_lookup(url) return current_url ~= '' and current_url == target_url end _store_status_hint_for_url = function(store, url, fallback) store = trim(tostring(store or '')) url = trim(tostring(url or '')) if store == '' or url == '' or not _current_store_url_status_matches(store, url) then return fallback end local status = tostring(_current_store_url_status.status or '') if status == 'pending' or status == 'checking' then return 'Checking current URL' end if status == 'found' then return 'Current URL already exists' end if status == 'missing' then return fallback or 'Current URL not found' end if status == 'error' then if fallback and fallback ~= '' then return fallback .. ' | lookup unavailable' end return 'Lookup unavailable' end return fallback end _refresh_current_store_url_status = function(reason) reason = trim(tostring(reason or '')) _ensure_selected_store_loaded() local store = trim(tostring(_get_selected_store() or '')) local target = _current_url_for_web_actions() or _current_target() local url = trim(tostring(target or '')) local normalized_url = _normalize_url_for_store_lookup(url) if url == '' or not _is_http_url(url) or _extract_store_hash(url) then _begin_current_store_url_status(store, url, 'idle') return end if normalized_url ~= '' and _skip_next_store_check_url ~= '' and normalized_url == _skip_next_store_check_url then _lua_log('store-check: skipped reason=' .. tostring(reason) .. ' url=' .. tostring(url) .. ' (format reload)') _skip_next_store_check_url = '' return end local generation = _begin_current_store_url_status(store, url, 'pending') if not _is_pipeline_helper_ready() then _lua_log('store-check: helper not ready; deferring reason=' .. tostring(reason) .. ' store=' .. tostring(store)) attempt_start_pipeline_helper_async(function(success) if _current_store_url_status.generation ~= generation then return end if not success then _set_current_store_url_status(store, url, 'error', 'helper not running') return end mp.add_timeout(0.1, function() if _current_store_url_status.generation ~= generation then return end _refresh_current_store_url_status((reason ~= '' and (reason .. '-retry')) or 'retry') end) end) return end _set_current_store_url_status(store, url, 'checking') _lua_log('store-check: starting reason=' .. tostring(reason) .. ' store=' .. tostring(store) .. ' url=' .. tostring(url)) _check_store_for_existing_url(store, url, function(matches, err, needle) if _current_store_url_status.generation ~= generation then return end local active_store = trim(tostring(_get_selected_store() or '')) local active_target = _current_url_for_web_actions() or _current_target() local active_url = trim(tostring(active_target or '')) if active_store ~= store or active_url ~= url then _lua_log('store-check: stale response discarded store=' .. tostring(store) .. ' url=' .. tostring(url)) return end if type(matches) == 'table' and #matches > 0 then _set_current_store_url_status(store, url, 'found', nil, #matches, needle) _lua_log('store-check: found matches=' .. tostring(#matches) .. ' needle=' .. tostring(needle or '')) return end local err_text = trim(tostring(err or '')) if err_text ~= '' then _set_current_store_url_status(store, url, 'error', err_text, 0, needle) _lua_log('store-check: lookup unavailable') return end _set_current_store_url_status(store, url, 'missing', nil, 0, needle) _lua_log('store-check: missing url=' .. tostring(url)) end) end local function _cache_formats_for_url(url, tbl) if type(url) ~= 'string' or url == '' then return end if type(tbl) ~= 'table' then return end _formats_cache[url] = { table = tbl, ts = mp.get_time() } if type(M.file) == 'table' and M.file.set_formats then M.file:set_formats(url, tbl) else M.file.url = url M.file.formats = tbl M.file.formats_table = tbl end end _get_cached_formats_table = function(url) if type(url) ~= 'string' or url == '' then return nil end local hit = _formats_cache[url] if type(hit) == 'table' and type(hit.table) == 'table' then return hit.table end return nil end local function _format_bytes_compact(size_bytes) local value = tonumber(size_bytes) if not value or value <= 0 then return '' end local units = { 'B', 'KB', 'MB', 'GB', 'TB' } local unit_index = 1 while value >= 1024 and unit_index < #units do value = value / 1024 unit_index = unit_index + 1 end if unit_index == 1 then return tostring(math.floor(value + 0.5)) .. units[unit_index] end return string.format('%.1f%s', value, units[unit_index]) end local function _is_browseable_raw_format(fmt) if type(fmt) ~= 'table' then return false end local format_id = trim(tostring(fmt.format_id or '')) if format_id == '' then return false end local ext = trim(tostring(fmt.ext or '')):lower() if ext == 'mhtml' or ext == 'json' then return false end local note = trim(tostring(fmt.format_note or fmt.format or '')):lower() if note:find('storyboard', 1, true) then return false end if format_id:lower():match('^sb') then return false end local vcodec = tostring(fmt.vcodec or 'none') local acodec = tostring(fmt.acodec or 'none') return not (vcodec == 'none' and acodec == 'none') end local function _build_formats_table_from_raw_info(url, raw) if raw == nil then raw = mp.get_property_native('ytdl-raw-info') end if type(raw) ~= 'table' then return nil, 'missing ytdl-raw-info' end local formats = raw.formats if type(formats) ~= 'table' or #formats == 0 then return nil, 'ytdl-raw-info has no formats' end local rows = {} local browseable_count = 0 for _, fmt in ipairs(formats) do if _is_browseable_raw_format(fmt) then browseable_count = browseable_count + 1 local format_id = trim(tostring(fmt.format_id or '')) local resolution = trim(tostring(fmt.resolution or '')) if resolution == '' then local width = tonumber(fmt.width) local height = tonumber(fmt.height) if width and height then resolution = tostring(math.floor(width)) .. 'x' .. tostring(math.floor(height)) elseif height then resolution = tostring(math.floor(height)) .. 'p' end end local ext = trim(tostring(fmt.ext or '')) local size = _format_bytes_compact(fmt.filesize or fmt.filesize_approx) local vcodec = tostring(fmt.vcodec or 'none') local acodec = tostring(fmt.acodec or 'none') local selection_id = format_id if vcodec ~= 'none' and acodec == 'none' then selection_id = format_id .. '+ba' end rows[#rows + 1] = { columns = { { name = 'ID', value = format_id }, { name = 'Resolution', value = resolution }, { name = 'Ext', value = ext }, { name = 'Size', value = size }, }, selection_args = { '-format', selection_id }, } end end if browseable_count == 0 then return { title = 'Formats', rows = {} }, nil end return { title = 'Formats', rows = rows }, nil end local function _summarize_formats_table(tbl, limit) if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' then return 'rows=0' end limit = tonumber(limit or 6) or 6 local parts = {} for i = 1, math.min(#tbl.rows, limit) do local row = tbl.rows[i] or {} local cols = row.columns or {} local id_val = '' local res_val = '' for _, c in ipairs(cols) do if c.name == 'ID' then id_val = tostring(c.value or '') end if c.name == 'Resolution' then res_val = tostring(c.value or '') end end parts[#parts + 1] = id_val ~= '' and (id_val .. (res_val ~= '' and ('@' .. res_val) or '')) or ('row' .. tostring(i)) end return 'rows=' .. tostring(#tbl.rows) .. ' sample=' .. table.concat(parts, ', ') end local function _cache_formats_from_raw_info(url, raw, source_label) url = trim(tostring(url or '')) if url == '' then return nil, 'missing url' end local tbl, err = _build_formats_table_from_raw_info(url, raw) if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' then return nil, err or 'raw format conversion failed' end _cache_formats_for_url(url, tbl) local summary = _summarize_formats_table(tbl, 8) local signature = url .. '|' .. summary if signature ~= _last_raw_format_summary then _last_raw_format_summary = signature _lua_log('formats: cached from ytdl-raw-info source=' .. tostring(source_label or 'unknown') .. ' ' .. summary) end return tbl, nil end local function _build_format_picker_items(tbl) local items = {} if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' then return items end for idx, row in ipairs(tbl.rows) do local cols = row.columns or {} local id_val = '' local res_val = '' local ext_val = '' local size_val = '' for _, c in ipairs(cols) do if c.name == 'ID' then id_val = tostring(c.value or '') end if c.name == 'Resolution' then res_val = tostring(c.value or '') end if c.name == 'Ext' then ext_val = tostring(c.value or '') end if c.name == 'Size' then size_val = tostring(c.value or '') end end local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx)) local hint_parts = {} if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end if ext_val ~= '' then table.insert(hint_parts, ext_val) end if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end local hint = table.concat(hint_parts, ' | ') local payload = { index = idx } items[#items + 1] = { title = label, hint = hint, value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) }, } end return items end local function _open_format_picker_for_table(url, tbl) if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or #tbl.rows == 0 then mp.osd_message('No formats available', 4) return false end _pending_format_change = _pending_format_change or { url = url, token = 'cached' } _pending_format_change.url = url _pending_format_change.formats_table = tbl local items = _build_format_picker_items(tbl) _debug_dump_formatted_formats(url, tbl, items) _show_format_list_osd(items, 8) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items) return true end local function _cache_formats_from_current_playback(reason, raw) local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then return false, 'no current target' end local url = tostring(target) if not _is_http_url(url) then return false, 'target not http' end local tbl, err = _cache_formats_from_raw_info(url, raw, reason) if type(tbl) == 'table' and type(tbl.rows) == 'table' then _lua_log('formats: playback cache ready source=' .. tostring(reason or 'unknown') .. ' rows=' .. tostring(#tbl.rows)) if type(_pending_format_change) == 'table' and tostring(_pending_format_change.url or '') == url and type(_pending_format_change.formats_table) ~= 'table' then _lua_log('change-format: fulfilling pending picker from playback cache') _open_format_picker_for_table(url, tbl) end return true, nil end return false, err end local function _run_formats_probe_async(url, cb) cb = cb or function() end url = trim(tostring(url or '')) if url == '' then cb(nil, 'missing url') return end local python = _resolve_python_exe(false) if not python or python == '' then cb(nil, 'no python executable available') return end local probe_script = _detect_format_probe_script() if probe_script == '' then cb(nil, 'format_probe.py not found') return end local cwd = _detect_repo_root() _lua_log('formats-probe: spawning subprocess python=' .. tostring(python) .. ' script=' .. tostring(probe_script) .. ' cwd=' .. tostring(cwd or '') .. ' url=' .. tostring(url)) mp.command_native_async( { name = 'subprocess', args = { python, probe_script, url }, capture_stdout = true, capture_stderr = true, playback_only = false, }, function(success, result, err) if not success then cb(nil, tostring(err or 'subprocess failed')) return end if type(result) ~= 'table' then cb(nil, 'invalid subprocess result') return end local status = tonumber(result.status or 0) or 0 local stdout = trim(tostring(result.stdout or '')) local stderr = trim(tostring(result.stderr or '')) if stdout == '' then local detail = stderr if detail == '' then detail = tostring(result.error or ('format probe exited with status ' .. tostring(status))) end cb(nil, detail) return end local ok, payload = pcall(utils.parse_json, stdout) if (not ok or type(payload) ~= 'table') and stdout ~= '' then local last_json_line = nil for line in stdout:gmatch('[^\r\n]+') do line = trim(tostring(line or '')) if line ~= '' then last_json_line = line end end if last_json_line and last_json_line ~= stdout then _lua_log('formats-probe: retrying json parse from last stdout line') ok, payload = pcall(utils.parse_json, last_json_line) end end if not ok or type(payload) ~= 'table' then cb(nil, 'invalid format probe json') return end if not payload.success then local detail = tostring(payload.error or payload.stderr or stderr or 'format probe failed') cb(payload, detail) return end cb(payload, nil) end ) end local function _schedule_playback_format_cache_poll(url, generation, attempt) if type(url) ~= 'string' or url == '' or generation ~= _format_cache_poll_generation then return end if _get_cached_formats_table(url) then return end attempt = tonumber(attempt or 1) or 1 local ok = select(1, _cache_formats_from_current_playback('poll-' .. tostring(attempt))) if ok then return end if attempt >= 12 then _lua_log('formats: playback cache poll exhausted for url=' .. url) return end mp.add_timeout(0.5, function() _schedule_playback_format_cache_poll(url, generation, attempt + 1) end) end function FileState:fetch_formats(cb) local url = tostring(self.url or '') _lua_log('fetch-formats: started for url=' .. url) if url == '' or not _is_http_url(url) then _lua_log('fetch-formats: skipped (not a url)') if cb then cb(false, 'not a url') end return end if _extract_store_hash(url) then _lua_log('fetch-formats: skipped (store-hash)') if cb then cb(false, 'store-hash url') end return end local cached = _get_cached_formats_table(url) if type(cached) == 'table' then _lua_log('fetch-formats: using cached table') self:set_formats(url, cached) if cb then cb(true, nil) end return end local raw_cached, raw_err = _cache_formats_from_raw_info(url, nil, 'fetch') if type(raw_cached) == 'table' then self:set_formats(url, raw_cached) _lua_log('fetch-formats: satisfied directly from ytdl-raw-info') if cb then cb(true, nil) end return end if raw_err and raw_err ~= '' then _lua_log('fetch-formats: ytdl-raw-info unavailable reason=' .. tostring(raw_err)) end local function _perform_request() if _formats_inflight[url] then _lua_log('fetch-formats: already inflight, adding waiter') _formats_waiters[url] = _formats_waiters[url] or {} if cb then table.insert(_formats_waiters[url], cb) end return end _lua_log('fetch-formats: initiating subprocess probe') _formats_inflight[url] = true _formats_waiters[url] = _formats_waiters[url] or {} if cb then table.insert(_formats_waiters[url], cb) end _run_formats_probe_async(url, function(resp, err) _lua_log('fetch-formats: subprocess callback received err=' .. tostring(err)) _formats_inflight[url] = nil local ok = false local reason = err if resp and resp.success and type(resp.table) == 'table' then ok = true reason = nil self:set_formats(url, resp.table) _cache_formats_for_url(url, resp.table) _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url via subprocess probe') else _lua_log('fetch-formats: request failed success=' .. tostring(resp and resp.success)) if type(resp) == 'table' then if resp.error and tostring(resp.error) ~= '' then reason = tostring(resp.error) elseif resp.stderr and tostring(resp.stderr) ~= '' then reason = tostring(resp.stderr) end end end local waiters = _formats_waiters[url] or {} _lua_log('fetch-formats: calling ' .. tostring(#waiters) .. ' waiters with ok=' .. tostring(ok) .. ' reason=' .. tostring(reason)) _formats_waiters[url] = nil for _, fn in ipairs(waiters) do pcall(fn, ok, reason) end end) end _perform_request() end local function _prefetch_formats_for_url(url, attempt) url = tostring(url or '') if url == '' or not _is_http_url(url) then return end attempt = tonumber(attempt or 1) or 1 local cached = _get_cached_formats_table(url) if type(cached) == 'table' and type(cached.rows) == 'table' and #cached.rows > 0 then _formats_prefetch_retries[url] = nil return end local raw_cached = nil raw_cached = select(1, _cache_formats_from_raw_info(url, nil, 'prefetch-' .. tostring(attempt))) if type(raw_cached) == 'table' and type(raw_cached.rows) == 'table' and #raw_cached.rows > 0 then _formats_prefetch_retries[url] = nil _lua_log('prefetch-formats: satisfied directly from ytdl-raw-info on attempt=' .. tostring(attempt)) return end _set_current_web_url(url) if type(M.file) == 'table' then if M.file.set_url then M.file:set_url(url) else M.file.url = url end if M.file.fetch_formats then M.file:fetch_formats(function(ok, err) if ok then _formats_prefetch_retries[url] = nil _lua_log('prefetch-formats: cached formats for url on attempt=' .. tostring(attempt)) return end local reason = tostring(err or '') local retryable = reason == 'helper not running' or reason == 'helper not ready' or reason:find('timeout waiting response', 1, true) ~= nil if not retryable then _formats_prefetch_retries[url] = nil _lua_log('prefetch-formats: giving up for url reason=' .. reason) return end local delays = { 1.5, 4.0, 8.0 } if attempt > #delays then _formats_prefetch_retries[url] = nil _lua_log('prefetch-formats: retries exhausted for url reason=' .. reason) return end local next_attempt = attempt + 1 if (_formats_prefetch_retries[url] or 0) >= next_attempt then return end _formats_prefetch_retries[url] = next_attempt local delay = delays[attempt] _lua_log('prefetch-formats: scheduling retry attempt=' .. tostring(next_attempt) .. ' delay=' .. tostring(delay) .. 's reason=' .. reason) mp.add_timeout(delay, function() if type(_get_cached_formats_table(url)) == 'table' then _formats_prefetch_retries[url] = nil return end _prefetch_formats_for_url(url, next_attempt) end) end) end end end local function _open_loading_formats_menu(title) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, title or 'Pick format', { { title = 'Loading formats…', hint = 'Fetching format list', value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' }, }, }) end _debug_dump_formatted_formats = function(url, tbl, items) local row_count = 0 if type(tbl) == 'table' and type(tbl.rows) == 'table' then row_count = #tbl.rows end local item_count = 0 if type(items) == 'table' then item_count = #items end _lua_log('formats-dump: url=' .. tostring(url or '') .. ' rows=' .. tostring(row_count) .. ' menu_items=' .. tostring(item_count)) -- Dump the formatted picker items (first 30) so we can confirm the -- list is being built and looks sane. if type(items) == 'table' then local limit = 30 for i = 1, math.min(#items, limit) do local it = items[i] or {} local title = tostring(it.title or '') local hint = tostring(it.hint or '') _lua_log('formats-item[' .. tostring(i) .. ']: ' .. title .. (hint ~= '' and (' | ' .. hint) or '')) end if #items > limit then _lua_log('formats-dump: (truncated; total=' .. tostring(#items) .. ')') end end end _show_format_list_osd = function(items, max_items) if type(items) ~= 'table' then return end local total = #items if total == 0 then mp.osd_message('No formats available', 4) return end local limit = max_items or 8 if limit < 1 then limit = 1 end local lines = {} for i = 1, math.min(total, limit) do local it = items[i] or {} local title = tostring(it.title or '') local hint = tostring(it.hint or '') if hint ~= '' then lines[#lines + 1] = title .. ' — ' .. hint else lines[#lines + 1] = title end end if total > limit then lines[#lines + 1] = '… +' .. tostring(total - limit) .. ' more' end if #lines > 0 then mp.osd_message(table.concat(lines, '\n'), 6) end end local function _current_ytdl_format_string() -- Preferred: mpv exposes the active ytdl format string. local fmt = trim(tostring(mp.get_property_native('ytdl-format') or '')) if fmt ~= '' then return fmt end -- Fallbacks: option value, or raw info if available. local opt = trim(tostring(mp.get_property('options/ytdl-format') or '')) if opt ~= '' then return opt end local raw = mp.get_property_native('ytdl-raw-info') if type(raw) == 'table' then if raw.format_id and tostring(raw.format_id) ~= '' then return tostring(raw.format_id) end local rf = raw.requested_formats if type(rf) == 'table' then local parts = {} for _, item in ipairs(rf) do if type(item) == 'table' and item.format_id and tostring(item.format_id) ~= '' then parts[#parts + 1] = tostring(item.format_id) end end if #parts >= 1 then return table.concat(parts, '+') end end end return nil end local function _resolve_cli_entrypoint() local configured = trim(tostring((opts and opts.cli_path) or '')) if configured ~= '' and configured ~= 'CLI.py' then if configured:match('[/\\]') then if _path_exists(configured) then return configured end else return configured end end local repo_root = _detect_repo_root() if repo_root ~= '' then local candidate = utils.join_path(repo_root, 'CLI.py') if _path_exists(candidate) then return candidate end end return configured ~= '' and configured or 'CLI.py' end local function _build_pipeline_cli_args(pipeline_cmd, seeds) pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then return nil, 'empty pipeline command' end local python = _resolve_python_exe(true) if not python or python == '' then python = _resolve_python_exe(false) end if not python or python == '' then return nil, 'python not found' end local cli_path = _resolve_cli_entrypoint() local args = { python, cli_path, 'pipeline', '--pipeline', pipeline_cmd } if seeds ~= nil then local seeds_json = utils.format_json(seeds) if type(seeds_json) == 'string' and seeds_json ~= '' then args[#args + 1] = '--seeds-json' args[#args + 1] = seeds_json end end return args, nil end local function _run_pipeline_cli_detached(pipeline_cmd, seeds) local args, build_err = _build_pipeline_cli_args(pipeline_cmd, seeds) if type(args) ~= 'table' then return false, tostring(build_err or 'invalid pipeline args') end local cmd = { name = 'subprocess', args = args, detach = true, } local ok, result, detail = _run_subprocess_command(cmd) if ok then _lua_log('pipeline-detached: spawned via cli cmd=' .. tostring(pipeline_cmd)) return true, detail end _lua_log('pipeline-detached: cli spawn failed detail=' .. tostring(detail or _describe_subprocess_result(result))) return false, detail or _describe_subprocess_result(result) end _run_pipeline_detached = function(pipeline_cmd, on_failure, seeds) if not pipeline_cmd or pipeline_cmd == '' then return false end local ok, detail = _run_pipeline_cli_detached(pipeline_cmd, seeds) if ok then return true end ensure_mpv_ipc_server() if not ensure_pipeline_helper_running() then if type(on_failure) == 'function' then on_failure(nil, detail ~= '' and detail or 'helper not running') end return false end _run_helper_request_async({ op = 'run-detached', data = { pipeline = pipeline_cmd, seeds = seeds } }, 1.0, function(resp, err) if resp and resp.success then return end if type(on_failure) == 'function' then on_failure(resp, err or detail) end end) return true end _run_pipeline_background_job = function(pipeline_cmd, seeds, on_started, on_complete, timeout_seconds, poll_interval_seconds) pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then if type(on_complete) == 'function' then on_complete(nil, 'empty pipeline command') end return false end ensure_mpv_ipc_server() if not ensure_pipeline_helper_running() then if type(on_complete) == 'function' then on_complete(nil, 'helper not running') end return false end _run_helper_request_async({ op = 'run-background', data = { pipeline = pipeline_cmd, seeds = seeds } }, 8.0, function(resp, err) if err or not resp or not resp.success then if type(on_complete) == 'function' then on_complete(nil, err or (resp and resp.error) or 'failed to start background job') end return end local job_id = trim(tostring(resp.job_id or '')) if job_id == '' then if type(on_complete) == 'function' then on_complete(nil, 'missing background job id') end return end if type(on_started) == 'function' then on_started(job_id, resp) end local deadline = mp.get_time() + math.max(tonumber(timeout_seconds or 0) or 0, 15.0) local poll_interval = math.max(tonumber(poll_interval_seconds or 0) or 0, 0.25) local poll_inflight = false local timer = nil local function finish(job, finish_err) if timer then timer:kill() timer = nil end if type(on_complete) == 'function' then on_complete(job, finish_err) end end timer = mp.add_periodic_timer(poll_interval, function() if poll_inflight then return end if mp.get_time() >= deadline then finish(nil, 'timeout waiting background job') return end poll_inflight = true _run_helper_request_async({ op = 'job-status', data = { job_id = job_id }, quiet = true }, math.max(poll_interval + 0.75, 1.25), function(status_resp, status_err) poll_inflight = false if status_err then _lua_log('background-job: poll retry job=' .. tostring(job_id) .. ' err=' .. tostring(status_err)) return end if not status_resp or not status_resp.success then local status_error = status_resp and status_resp.error or 'job status unavailable' finish(nil, status_error) return end local job = status_resp.job local status = type(job) == 'table' and tostring(job.status or '') or tostring(status_resp.status or '') if status == 'success' or status == 'failed' then finish(job, nil) end end) end) end) return true end local function _open_save_location_picker_for_pending_download() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end local clip_range = trim(tostring(_pending_download.clip_range or '')) local title = clip_range ~= '' and ('Save clip ' .. clip_range) or 'Save location' local function build_items() local selected = _get_selected_store() local items = { { title = 'Pick folder…', hint = clip_range ~= '' and ('Save clip ' .. clip_range .. ' to a local folder') or 'Save to a local folder', value = { 'script-message-to', mp.get_script_name(), 'medios-download-pick-path', '{}' }, }, } 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 } local hint = _store_status_hint_for_url(name, tostring(_pending_download.url or ''), nil) if (not hint or hint == '') and selected ~= '' and name == selected then hint = 'Current store' end items[#items + 1] = { title = name, hint = hint, active = (selected ~= '' and name == selected) and true or false, value = { 'script-message-to', mp.get_script_name(), 'medios-download-pick-store', utils.format_json(payload) }, } end end end return items end -- Always open immediately with whatever store cache we have. _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, title, build_items()) -- Best-effort refresh; if it succeeds, reopen menu with stores. mp.add_timeout(0.05, function() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end _refresh_store_cache(1.5, function(success, changed) if success and changed then _uosc_open_list_picker(DOWNLOAD_STORE_MENU_TYPE, title, build_items()) end end) end) 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 pcall(_refresh_current_store_url_status, 'startup') end) local function _apply_ytdl_format_and_reload(url, fmt) if not url or url == '' or not fmt or fmt == '' then return end local pos = mp.get_property_number('time-pos') local paused = mp.get_property_native('pause') and true or false local media_title = trim(tostring(mp.get_property('media-title') or '')) _lua_log('change-format: setting ytdl format=' .. tostring(fmt)) _skip_next_store_check_url = _normalize_url_for_store_lookup(url) _set_current_web_url(url) pcall(mp.set_property, 'options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'file-local-options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'ytdl-format', tostring(fmt)) local load_options = { ['ytdl-format'] = tostring(fmt), } if pos and pos > 0 then load_options['start'] = tostring(pos) end if media_title ~= '' then load_options['force-media-title'] = media_title end if paused then load_options['pause'] = 'yes' end _lua_log('change-format: reloading current url with per-file options') mp.command_native({ 'loadfile', url, 'replace', -1, load_options }) if paused then mp.set_property_native('pause', true) end end local function _start_download_flow_for_current() local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then mp.osd_message('No current item', 2) return end _lua_log('download: current target=' .. tostring(target)) local store_hash = _extract_store_hash(target) if store_hash then if not _is_windows() then mp.osd_message('Download folder picker is Windows-only', 4) return end local folder = _pick_folder_windows() if not folder or folder == '' then return end ensure_mpv_ipc_server() local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) _queue_pipeline_in_repl( pipeline_cmd, 'Queued in REPL: store copy', 'REPL queue failed', 'download-store-copy', { mpv_notify = { success_text = 'Copy completed: store ' .. tostring(store_hash.store), failure_text = 'Copy failed: store ' .. tostring(store_hash.store), duration_ms = 3500, }, } ) return end -- Non-store URL flow: use the current yt-dlp-selected format and ask for save location. local url = tostring(target) local download_url, stripped_playlist = _download_url_for_current_item(url) if stripped_playlist then _lua_log('download: stripped hidden playlist params from current url -> ' .. tostring(download_url)) url = tostring(download_url) end local fmt = _current_ytdl_format_string() if not fmt or fmt == '' then _lua_log('download: could not determine current ytdl format string') mp.osd_message('Cannot determine current format; use Change Format first', 5) return end _lua_log('download: using current format=' .. tostring(fmt)) local clip_range = _get_trim_range_from_clip_markers() _pending_download = { url = url, format = fmt, clip_range = clip_range, } if clip_range and clip_range ~= '' then _lua_log('download: using clip_range=' .. tostring(clip_range)) else local clip_marker_count = 0 local marker_label = nil for idx = 1, CLIP_MARKER_SLOT_COUNT do local marker_time = clip_markers[idx] if type(marker_time) == 'number' then clip_marker_count = clip_marker_count + 1 if not marker_label then marker_label = _format_clip_marker_label(marker_time) end end end if clip_marker_count == 1 then _lua_log('download: single clip marker detected; asking whether to continue with full download') if not ensure_uosc_loaded() then mp.osd_message('Only one clip marker is set. Set the second marker or clear markers before downloading.', 4) return end _uosc_open_list_picker('medios_download_clip_decision', marker_label and ('One clip marker set: ' .. marker_label) or 'One clip marker set', { { title = 'Download full item', hint = 'Ignore the single marker and continue', value = { 'script-message-to', mp.get_script_name(), 'medios-download-proceed-full', '{}' }, }, { title = 'Clear clip markers and download full item', hint = 'Reset markers, then continue with a full download', value = { 'script-message-to', mp.get_script_name(), 'medios-download-clear-markers-and-proceed', '{}' }, }, { title = 'Go back and edit clip markers', hint = 'Set the second marker or replace the existing one', value = { 'script-message-to', mp.get_script_name(), 'medios-download-edit-markers', '{}' }, }, }) return end end _open_save_location_picker_for_pending_download() end mp.register_script_message('medios-download-current', function() _start_download_flow_for_current() end) mp.register_script_message('medios-change-format-current', function() local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then mp.osd_message('No current item', 2) return end local store_hash = _extract_store_hash(target) if store_hash then mp.osd_message('Change Format is only for URL playback', 4) return end local url = tostring(target) _set_current_web_url(url) -- Ensure file state is tracking the current URL. if type(M.file) == 'table' then if M.file.set_url then M.file:set_url(url) else M.file.url = url end end -- If formats were already prefetched for this URL, open instantly. local cached_tbl = nil if type(M.file) == 'table' and M.file.url == url and type(M.file.formats) == 'table' then cached_tbl = M.file.formats else cached_tbl = _get_cached_formats_table(url) end if not (type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0) then cached_tbl = select(1, _cache_formats_from_raw_info(url)) end if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then _pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl } _open_format_picker_for_table(url, cached_tbl) return end local token = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) _pending_format_change = { url = url, token = token } _open_loading_formats_menu('Change format') -- Non-blocking: ask the per-file state to fetch formats in the background. if type(M.file) == 'table' and M.file.fetch_formats then _lua_log('change-format: formats not cached yet; fetching in background') M.file:fetch_formats(function(ok, err) if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then return end if not ok then local msg2 = tostring(err or '') if msg2 == '' then msg2 = 'unknown' end _lua_log('change-format: formats failed: ' .. msg2) mp.osd_message('Failed to load format list: ' .. msg2, 7) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', { { title = 'Failed to load format list', hint = msg2, value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' }, }, }) return end local tbl = (type(M.file.formats) == 'table') and M.file.formats or _get_cached_formats_table(url) if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or #tbl.rows == 0 then mp.osd_message('No formats available', 4) return end _pending_format_change.formats_table = tbl _open_format_picker_for_table(url, tbl) end) end end) -- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant. mp.register_event('file-loaded', function() _sync_current_web_url_from_playback() _refresh_current_store_url_status('file-loaded') local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then return end local url = tostring(target) if not _is_http_url(url) then return end local ok, err = _cache_formats_from_current_playback('file-loaded') if ok then _lua_log('formats: file-loaded cache succeeded for url=' .. url) else _lua_log('formats: file-loaded cache pending reason=' .. tostring(err or 'unknown')) end _format_cache_poll_generation = _format_cache_poll_generation + 1 _schedule_playback_format_cache_poll(url, _format_cache_poll_generation, 1) _prefetch_formats_for_url(url) end) mp.observe_property('ytdl-raw-info', 'native', function(_name, value) if type(value) ~= 'table' then return end local ok, err = _cache_formats_from_current_playback('observe-ytdl-raw-info', value) if not ok and err and err ~= '' then _lua_log('formats: observe-ytdl-raw-info pending reason=' .. tostring(err)) end end) mp.register_script_message('medios-change-format-pick', function(json) if type(_pending_format_change) ~= 'table' or not _pending_format_change.url then return end local ok, ev = pcall(utils.parse_json, json) if not ok or type(ev) ~= 'table' then return end local idx = tonumber(ev.index or 0) or 0 if idx <= 0 then return end local tbl = _pending_format_change.formats_table if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or not tbl.rows[idx] then return end local row = tbl.rows[idx] local sel = row.selection_args local fmt = nil if type(sel) == 'table' then for i = 1, #sel do if tostring(sel[i]) == '-format' and sel[i + 1] then fmt = tostring(sel[i + 1]) break end end end if not fmt or fmt == '' then mp.osd_message('Invalid format selection', 3) return end local url = tostring(_pending_format_change.url) _pending_format_change = nil _apply_ytdl_format_and_reload(url, fmt) end) mp.register_script_message('medios-download-pick-store', function(json) if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end 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 local url = tostring(_pending_download.url) local fmt = tostring(_pending_download.format) local clip_range = trim(tostring(_pending_download.clip_range or '')) local query = 'format:' .. fmt if clip_range ~= '' then query = query .. ',clip:' .. clip_range end local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -query ' .. quote_pipeline_arg(query) .. ' | add-file -store ' .. quote_pipeline_arg(store) _set_selected_store(store) _queue_pipeline_in_repl( pipeline_cmd, 'Queued in REPL: save to store ' .. store, 'REPL queue failed', 'download-store-save', { mpv_notify = { success_text = 'Download completed: store ' .. store .. ' [' .. fmt .. ']' .. clip_suffix, failure_text = 'Download failed: store ' .. store .. ' [' .. fmt .. ']' .. clip_suffix, duration_ms = 3500, }, } ) _pending_download = nil end) mp.register_script_message('medios-download-proceed-full', function() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end _pending_download.clip_range = nil _open_save_location_picker_for_pending_download() end) mp.register_script_message('medios-download-clear-markers-and-proceed', function() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end _reset_clip_markers() _pending_download.clip_range = nil _open_save_location_picker_for_pending_download() end) mp.register_script_message('medios-download-edit-markers', function() _pending_download = nil mp.osd_message('Edit clip markers, then run Download again', 4) end) mp.register_script_message('medios-download-pick-path', function() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end if not _is_windows() then mp.osd_message('Folder picker is Windows-only', 4) return end local folder = _pick_folder_windows() if not folder or folder == '' then return end local url = tostring(_pending_download.url) local fmt = tostring(_pending_download.format) local clip_range = trim(tostring(_pending_download.clip_range or '')) local query = 'format:' .. fmt if clip_range ~= '' then query = query .. ',clip:' .. clip_range end local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -query ' .. quote_pipeline_arg(query) .. ' | add-file -path ' .. quote_pipeline_arg(folder) _queue_pipeline_in_repl( pipeline_cmd, 'Queued in REPL: save to folder', 'REPL queue failed', 'download-folder-save', { mpv_notify = { success_text = 'Download completed: folder [' .. fmt .. ']' .. clip_suffix, failure_text = 'Download failed: folder [' .. fmt .. ']' .. clip_suffix, duration_ms = 3500, }, } ) _pending_download = nil end) local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds) if not ensure_pipeline_helper_running() then return nil end -- Avoid a race where we send the request before the helper has connected -- and installed its property observer, which would cause a timeout and -- force a noisy CLI fallback. do local deadline = mp.get_time() + 1.0 while mp.get_time() < deadline do if _is_pipeline_helper_ready() then break end mp.wait_event(0.05) end if not _is_pipeline_helper_ready() then _pipeline_helper_started = false return nil end end local id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) local req = { id = id, pipeline = pipeline_cmd } if seeds then req.seeds = seeds end -- Clear any previous response to reduce chances of reading stale data. mp.set_property(PIPELINE_RESP_PROP, '') mp.set_property(PIPELINE_REQ_PROP, utils.format_json(req)) local deadline = mp.get_time() + (timeout_seconds or 5) while mp.get_time() < deadline do local resp_json = mp.get_property(PIPELINE_RESP_PROP) if resp_json and resp_json ~= '' then local ok, resp = pcall(utils.parse_json, resp_json) if ok and resp and resp.id == id then if resp.success then return resp.stdout or '' end local details = '' if resp.error and tostring(resp.error) ~= '' then details = tostring(resp.error) end if resp.stderr and tostring(resp.stderr) ~= '' then if details ~= '' then details = details .. "\n" end details = details .. tostring(resp.stderr) end local log_path = resp.log_path if log_path and tostring(log_path) ~= '' then details = (details ~= '' and (details .. "\n") or '') .. 'Log: ' .. tostring(log_path) end return nil, (details ~= '' and details or 'unknown') end end mp.wait_event(0.05) end -- Helper may have crashed or never started; allow retry on next call. _pipeline_helper_started = false return nil end -- Detect CLI path local function detect_script_dir() local dir = mp.get_script_directory() if dir and dir ~= "" then return dir end -- Fallback to debug info path local src = debug.getinfo(1, "S").source if src and src:sub(1, 1) == "@" then local path = src:sub(2) local parent = path:match("(.*)[/\\]") if parent and parent ~= "" then return parent end end -- Fallback to working directory local cwd = utils.getcwd() if cwd and cwd ~= "" then return cwd end return nil end local script_dir = detect_script_dir() or "" if not opts.cli_path then -- Try to locate CLI.py by walking up from this script directory. -- Typical layout here is: /MPV/LUA/main.lua, and /CLI.py opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py" end -- Ref: mpv_lua_api.py was removed in favor of pipeline_helper (run_pipeline_via_ipc_response). -- This placeholder comment ensures we don't have code shifting issues. -- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). -- Calls the callback with stdout on success or error message on failure. function M.run_pipeline(pipeline_cmd, seeds, cb) _lua_log('M.run_pipeline called with cmd: ' .. tostring(pipeline_cmd)) cb = cb or function() end pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then _lua_log('M.run_pipeline: empty command') cb(nil, 'empty pipeline command') return end ensure_mpv_ipc_server() -- Use a longer timeout for `.mpv -url` commands to avoid races with slow helper starts. local lower_cmd = pipeline_cmd:lower() local is_mpv_load = lower_cmd:match('%.mpv%s+%-url') ~= nil local timeout_seconds = is_mpv_load and 45 or 30 _run_pipeline_request_async(pipeline_cmd, seeds, timeout_seconds, function(resp, err) _lua_log('M.run_pipeline callback fired: resp=' .. tostring(resp) .. ', err=' .. tostring(err)) if resp and resp.success then _lua_log('M.run_pipeline: success') cb(resp.stdout or '', nil) return end local details = err or '' if details == '' and type(resp) == 'table' then if resp.error and tostring(resp.error) ~= '' then details = tostring(resp.error) elseif resp.stderr and tostring(resp.stderr) ~= '' then details = tostring(resp.stderr) end end if details == '' then details = 'unknown' end _lua_log('pipeline failed cmd=' .. tostring(pipeline_cmd) .. ' err=' .. details) cb(nil, details) end) end -- Helper to run pipeline and parse JSON output function M.run_pipeline_json(pipeline_cmd, seeds, cb) cb = cb or function() end pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then cb(nil, 'empty pipeline command') return end ensure_mpv_ipc_server() local lower_cmd = pipeline_cmd:lower() local is_mpv_load = lower_cmd:match('%.mpv%s+%-url') ~= nil local timeout_seconds = is_mpv_load and 45 or 30 _run_pipeline_request_async(pipeline_cmd, seeds, timeout_seconds, function(resp, err) if resp and resp.success then if type(resp.data) == 'table' then cb(resp.data, nil) return end local output = trim(tostring(resp.stdout or '')) if output ~= '' then local ok, data = pcall(utils.parse_json, output) if ok then cb(data, nil) return end _lua_log('Failed to parse JSON: ' .. output) cb(nil, 'malformed JSON response') return end cb({}, nil) return end local details = err or '' if details == '' and type(resp) == 'table' then if resp.error and tostring(resp.error) ~= '' then details = tostring(resp.error) elseif resp.stderr and tostring(resp.stderr) ~= '' then details = tostring(resp.stderr) end end cb(nil, details ~= '' and details or 'unknown') end) end -- Command: Get info for current file function M.get_file_info() local path = mp.get_property('path') if not path then return end local seed = {{path = path}} M.run_pipeline_json('get-metadata', seed, function(data, err) if data then _lua_log('Metadata: ' .. utils.format_json(data)) mp.osd_message('Metadata loaded (check console)', 3) return end if err then mp.osd_message('Failed to load metadata: ' .. tostring(err), 3) end end) end -- Command: Delete current file function M.delete_current_file() local path = mp.get_property('path') if not path then return end local seed = {{path = path}} M.run_pipeline('delete-file', seed, function(_, err) if err then mp.osd_message('Delete failed: ' .. tostring(err), 3) return end mp.osd_message('File deleted', 3) mp.command('playlist-next') end) end -- Command: Load a URL via pipeline (Ctrl+Enter in prompt) function M.open_load_url_prompt() _lua_log('open_load_url_prompt called') local menu_data = { type = LOAD_URL_MENU_TYPE, title = 'Load URL', search_style = 'palette', search_debounce = 'submit', on_search = 'callback', footnote = 'Paste/type URL, then Ctrl+Enter to load.', callback = {mp.get_script_name(), 'medios-load-url-event'}, items = {}, } local json = utils.format_json(menu_data) if ensure_uosc_loaded() then _lua_log('open_load_url_prompt: sending menu to uosc') mp.commandv('script-message-to', 'uosc', 'open-menu', json) else _lua_log('menu: uosc not available; cannot open-menu for load-url') end end -- Open the command submenu with tailored cmdlets (screenshot, clip, trim prompt) function M.open_cmd_menu() local items = { { title = 'Screenshot', hint = 'Capture a screenshot', value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'screenshot' }) }, }, { title = 'Capture clip marker', hint = 'Place a clip marker at current time', value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'clip' }) }, }, { title = 'Trim file', hint = 'Trim current file (prompt for range)', value = { 'script-message-to', mp.get_script_name(), 'medios-cmd-exec', utils.format_json({ cmd = 'trim' }) }, }, } local menu_data = { type = CMD_MENU_TYPE, title = 'Cmd', search_style = 'palette', search_debounce = 'submit', footnote = 'Type to filter or pick a command', items = items, } local json = utils.format_json(menu_data) if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'open-menu', json) else _lua_log('menu: uosc not available; cannot open cmd menu') end 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) local trim_module = nil local trim_path = nil local load_err = nil local ok_trim = false ok_trim, trim_module, trim_path, load_err = _load_lua_chunk_from_candidates('trim', 'trim.lua') 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 if ok_trim and trim_path then _lua_log('trim: using module at ' .. tostring(trim_path)) 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')) 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')) 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) .. ' | 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 = '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 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 PIPELINE HELPER FOR UPLOAD ===') run_pipeline_via_ipc_async(pipeline_cmd, nil, 60, function(response, err) if not response then response = { success = false, error = err or 'Timeout or IPC error' } end _lua_log('trim: api response success=' .. tostring(response.success)) _lua_log('trim: api response error=' .. tostring(response.error or 'nil')) _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 = err or response.error or response.stderr or 'unknown error' mp.osd_message('Upload failed: ' .. err_msg, 5) _lua_log('trim: upload FAILED - ' .. err_msg) end end) 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" .. store_hint, callback = { mp.get_script_name(), 'medios-trim-run' }, items = { { title = 'Enter range...', hint = 'Type range and press Enter', value = { 'script-message-to', mp.get_script_name(), 'medios-trim-run' }, } } } local json = utils.format_json(menu_data) if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'open-menu', json) else _lua_log('menu: uosc not available; cannot open trim prompt') end end -- Handlers for the command submenu mp.register_script_message('medios-open-cmd', function() M.open_cmd_menu() end) mp.register_script_message('medios-cmd-exec', function(json) local ok, ev = pcall(utils.parse_json, json) if not ok or type(ev) ~= 'table' then return end local cmd = trim(tostring(ev.cmd or '')) if cmd == 'screenshot' then _capture_screenshot() elseif cmd == 'clip' then _capture_clip() elseif cmd == 'trim' then M.open_trim_prompt() else mp.osd_message('Unknown cmd ' .. tostring(cmd), 2) end end) mp.register_script_message('medios-trim-run', function(json) local ok, ev = pcall(utils.parse_json, json) local range = nil if ok and type(ev) == 'table' then if ev.type == 'search' then range = trim(tostring(ev.query or '')) end end _start_trim_with_range(range) end) mp.register_script_message('medios-load-url', function() _lua_log('medios-load-url handler called') -- Close the main menu first if ensure_uosc_loaded() then _lua_log('medios-load-url: closing main menu before opening Load URL prompt') mp.commandv('script-message-to', 'uosc', 'close-menu') end M.open_load_url_prompt() end) mp.register_script_message('medios-start-helper', function() -- Asynchronously start the pipeline helper without blocking the menu. attempt_start_pipeline_helper_async(function(success) if success then mp.osd_message('Pipeline helper started', 2) else mp.osd_message('Failed to start pipeline helper (check logs)', 3) end end) end) mp.register_script_message('medios-load-url-event', function(json) _lua_log('[LOAD-URL] Event handler called with: ' .. tostring(json or 'nil')) local ok, event = pcall(utils.parse_json, json) if not ok or type(event) ~= 'table' then _lua_log('[LOAD-URL] Failed to parse JSON: ' .. tostring(json)) mp.osd_message('Failed to parse URL', 2) if ensure_uosc_loaded() then _lua_log('[LOAD-URL] Closing menu due to parse error') mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) end return end _lua_log('[LOAD-URL] Parsed event: type=' .. tostring(event.type) .. ', query=' .. tostring(event.query)) if event.type ~= 'search' then _lua_log('[LOAD-URL] Event type is not search: ' .. tostring(event.type)) if ensure_uosc_loaded() then _lua_log('[LOAD-URL] Closing menu due to type mismatch') mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) end return end local url = trim(tostring(event.query or '')) _lua_log('[LOAD-URL] Trimmed URL: "' .. url .. '"') if url == '' then _lua_log('[LOAD-URL] URL is empty') _log_all('ERROR', 'Load URL failed: URL is empty') mp.osd_message('URL is empty', 2) if ensure_uosc_loaded() then _lua_log('[LOAD-URL] Closing menu due to empty URL') mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) end return end mp.osd_message('Loading URL...', 1) _log_all('INFO', 'Load URL started: ' .. url) _lua_log('[LOAD-URL] Starting to load: ' .. url) _set_current_web_url(url) local function close_menu() _lua_log('[LOAD-URL] Closing menu') if ensure_uosc_loaded() then _lua_log('[LOAD-URL] Sending close-menu command to UOSC') mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) else _lua_log('[LOAD-URL] UOSC not loaded, cannot close menu') end end -- Close the URL prompt immediately once the user submits. Playback may still -- take time to resolve, but the modal should not stay stuck on screen. close_menu() -- First, always try direct loadfile. This is the fastest path. local can_direct = _url_can_direct_load(url) local prefer_direct = can_direct or _is_ytdlp_url(url) _lua_log('[LOAD-URL] Checking if URL can be loaded directly: ' .. tostring(can_direct)) _lua_log('[LOAD-URL] Prefer direct load: ' .. tostring(prefer_direct)) local direct_ok, direct_loaded = _try_direct_loadfile(url, prefer_direct) if direct_ok and direct_loaded then _lua_log('[LOAD-URL] Direct loadfile command sent successfully (forced)') _log_all('INFO', 'Load URL succeeded via direct load') mp.osd_message('URL loaded', 2) return end if direct_ok then _lua_log('[LOAD-URL] Direct loadfile command did not load the URL; falling back to helper') else _lua_log('[LOAD-URL] Direct loadfile command failed; falling back to helper') end -- Complex streams (YouTube, DASH, etc.) need the pipeline helper. _lua_log('[LOAD-URL] URL requires pipeline helper for processing') ensure_mpv_ipc_server() local helper_ready = ensure_pipeline_helper_running() _lua_log('[LOAD-URL] Pipeline helper ready: ' .. tostring(helper_ready)) local function start_pipeline_load() -- Use pipeline to download/prepare the URL local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play' _lua_log('[LOAD-URL] Sending to pipeline: ' .. pipeline_cmd) _lua_log('[LOAD-URL] Pipeline helper ready: ' .. tostring(_is_pipeline_helper_ready())) local timeout_timer = nil timeout_timer = mp.add_timeout(5, function() if timeout_timer then mp.osd_message('Still loading... (helper may be resolving URL)', 2) _log_all('WARN', 'Load URL still processing after 5 seconds') _lua_log('[LOAD-URL] Timeout message shown (helper still processing)') end end) M.run_pipeline(pipeline_cmd, nil, function(resp, err) if timeout_timer then timeout_timer:kill() timeout_timer = nil end _lua_log('[LOAD-URL] Pipeline callback received: resp=' .. tostring(resp) .. ', err=' .. tostring(err)) if err then _lua_log('[LOAD-URL] Pipeline error: ' .. tostring(err)) _log_all('ERROR', 'Load URL pipeline failed: ' .. tostring(err)) mp.osd_message('Load URL failed: ' .. tostring(err), 3) return end _lua_log('[LOAD-URL] URL loaded successfully') _log_all('INFO', 'Load URL succeeded') mp.osd_message('URL loaded', 2) if _is_ytdlp_url(url) then _lua_log('[LOAD-URL] URL is yt-dlp compatible, prefetching formats in background') mp.add_timeout(0.5, function() _prefetch_formats_for_url(url) end) end end) end if not helper_ready then _lua_log('[LOAD-URL] Pipeline helper not available, attempting to start...') _log_all('WARN', 'Pipeline helper not running, attempting auto-start') mp.osd_message('Starting pipeline helper...', 2) -- Attempt to start the helper asynchronously attempt_start_pipeline_helper_async(function(success) if success then _lua_log('[LOAD-URL] Helper started successfully, continuing load') _log_all('INFO', 'Pipeline helper started successfully') start_pipeline_load() else _lua_log('[LOAD-URL] Failed to start helper') _log_all('ERROR', 'Failed to start pipeline helper') mp.osd_message('Could not start pipeline helper', 3) end end) return end start_pipeline_load() end) -- Menu integration with UOSC function M.show_menu() _lua_log('[MENU] M.show_menu called') local target = _current_target() local selected_store = trim(tostring(_get_selected_store() or '')) local download_hint = nil if selected_store ~= '' and target and not _extract_store_hash(tostring(target)) then download_hint = _store_status_hint_for_url(selected_store, tostring(target), 'save to ' .. selected_store) end _lua_log('[MENU] current target: ' .. tostring(target)) -- Build menu items -- Note: UOSC expects command strings, not arrays local items = { { title = "Load URL", value = "script-message medios-load-url" }, { title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" }, { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" }, { title = "Cmd", value = "script-message medios-open-cmd", hint = "screenshot/trim/etc" }, { title = "Download", value = "script-message medios-download-current", hint = download_hint }, } if _is_ytdlp_url(target) then table.insert(items, { title = "Change Format", value = "script-message medios-change-format-current" }) end _lua_log('[MENU] Built ' .. #items .. ' menu items') -- Check UOSC availability local uosc_ready = ensure_uosc_loaded() _lua_log('[MENU] ensure_uosc_loaded returned: ' .. tostring(uosc_ready)) if not uosc_ready then _lua_log('[MENU] ERROR: uosc not available; menu cannot open') mp.osd_message('Menu unavailable (uosc not loaded)', 3) return end -- Format menu for UOSC local menu_data = { title = "Medios Macina", items = items, } local json = utils.format_json(menu_data) _lua_log('[MENU] Sending menu JSON to uosc: ' .. string.sub(json, 1, 200) .. '...') -- Try to open menu via uosc script message -- Note: UOSC expects JSON data as a string parameter local ok, err = pcall(function() -- Method 1: Try commandv with individual arguments mp.commandv('script-message-to', 'uosc', 'open-menu', json) end) if not ok then _lua_log('[MENU] Method 1 failed: ' .. tostring(err)) -- Method 2: Try command with full string local cmd = 'script-message-to uosc open-menu ' .. json local ok2, err2 = pcall(function() mp.command(cmd) end) if not ok2 then _lua_log('[MENU] Method 2 failed: ' .. tostring(err2)) mp.osd_message('Menu error', 3) else _lua_log('[MENU] Menu command sent via method 2') end else _lua_log('[MENU] Menu command sent successfully') end end -- Keybindings with logging wrappers mp.add_key_binding("m", "medios-menu", function() _lua_log('[KEY] m pressed') M.show_menu() end) mp.add_key_binding("z", "medios-menu-alt", function() _lua_log('[KEY] z pressed (alternative menu trigger)') M.show_menu() end) -- NOTE: mbtn_right is claimed by UOSC globally, so we can't override it here. -- Instead, use script-message handler below for alternative routing. mp.add_key_binding("ctrl+i", "medios-info", M.get_file_info) mp.add_key_binding("ctrl+del", "medios-delete", M.delete_current_file) -- Lyrics toggle (requested: 'L') mp.add_key_binding("l", "medeia-lyric-toggle", lyric_toggle) mp.add_key_binding("L", "medeia-lyric-toggle-shift", lyric_toggle) -- Script message handler for input.conf routing (right-click via input.conf) mp.register_script_message('medios-show-menu', function() _lua_log('[input.conf] medios-show-menu called') M.show_menu() end) -- Start the persistent pipeline helper eagerly at launch. -- This avoids spawning Python per command and works cross-platform via MPV IPC. mp.add_timeout(0, function() pcall(ensure_mpv_ipc_server) pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION) local ok_helper, helper_err = pcall(function() attempt_start_pipeline_helper_async(function(success) if success then _lua_log('helper-auto-start succeeded') else _lua_log('helper-auto-start failed') end end) end) if not ok_helper then _lua_log('helper-auto-start raised: ' .. tostring(helper_err)) end -- Try to re-register right-click after UOSC loads (might override its binding) mp.add_timeout(1.0, function() _lua_log('[KEY] attempting to re-register mbtn_right after UOSC loaded') pcall(function() mp.add_key_binding("mbtn_right", "medios-menu-right-click-late", function() _lua_log('[KEY] mbtn_right pressed (late registration attempt)') M.show_menu() end, {repeatable=false}) end) end) end) return M