- Add [LOAD-URL] prefix to all handler logs for easy filtering - Log each stage of URL processing: parse, validate, direct-load, pipeline - Add close_menu diagnostic logging - Create test_load_url.py script for manual testing - Improve error messages and status feedback This will make it much easier to diagnose why Load URL isn't working.
2980 lines
100 KiB
Lua
2980 lines
100 KiB
Lua
local mp = require 'mp'
|
|
local utils = require 'mp.utils'
|
|
local msg = require 'mp.msg'
|
|
|
|
local M = {}
|
|
|
|
local MEDEIA_LUA_VERSION = '2025-12-24'
|
|
|
|
-- Expose a tiny breadcrumb for debugging which script version is loaded.
|
|
pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION)
|
|
|
|
-- Track whether uosc is available so menu calls don't fail with
|
|
-- "Can't find script 'uosc' to send message to."
|
|
local _uosc_loaded = false
|
|
|
|
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'
|
|
|
|
-- 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 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'
|
|
|
|
-- Dedicated Lua log (next to mpv log-file) because mp.msg output is not always
|
|
-- included in --log-file depending on msg-level and build.
|
|
local function _lua_log(text)
|
|
local payload = (text and tostring(text) or '')
|
|
if payload == '' then
|
|
return
|
|
end
|
|
local dir = ''
|
|
|
|
-- Prefer a stable repo-root Log/ folder based on the script directory.
|
|
do
|
|
local function _dirname(p)
|
|
p = tostring(p or '')
|
|
p = p:gsub('[/\\]+$', '')
|
|
return p:match('(.*)[/\\]') or ''
|
|
end
|
|
|
|
local base = mp.get_script_directory() or ''
|
|
if base ~= '' then
|
|
-- base is expected to be <repo>/MPV/LUA
|
|
local root = _dirname(_dirname(base))
|
|
if root ~= '' then
|
|
dir = utils.join_path(root, 'Log')
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Prefer repo-root Log/ for consistency with Python helper logs.
|
|
do
|
|
local function find_up(start_dir, relative_path, max_levels)
|
|
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', 8)
|
|
if cli and cli ~= '' then
|
|
local root = cli:match('(.*)[/\\]') or ''
|
|
if root ~= '' then
|
|
dir = utils.join_path(root, 'Log')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fallback: next to mpv --log-file.
|
|
if dir == '' then
|
|
local log_file = mp.get_property('options/log-file') or ''
|
|
dir = log_file:match('(.*)[/\\]') or ''
|
|
end
|
|
if dir == '' then
|
|
dir = mp.get_script_directory() or utils.getcwd() or ''
|
|
end
|
|
if dir == '' then
|
|
return
|
|
end
|
|
local path = utils.join_path(dir, 'medeia-mpv-lua.log')
|
|
local fh = io.open(path, 'a')
|
|
if not fh then
|
|
return
|
|
end
|
|
local line = '[' .. os.date('%Y-%m-%d %H:%M:%S') .. '] ' .. payload
|
|
fh:write(line .. '\n')
|
|
fh:close()
|
|
|
|
-- Also mirror Lua-side debug into the Python helper log file so there's one
|
|
-- place to look when diagnosing mpv↔python IPC issues.
|
|
do
|
|
local helper_path = utils.join_path(dir, 'medeia-mpv-helper.log')
|
|
local fh2 = io.open(helper_path, 'a')
|
|
if fh2 then
|
|
fh2:write('[lua] ' .. line .. '\n')
|
|
fh2:close()
|
|
end
|
|
end
|
|
end
|
|
|
|
_lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))
|
|
|
|
local function ensure_uosc_loaded()
|
|
if _uosc_loaded or _is_script_loaded('uosc') then
|
|
_uosc_loaded = true
|
|
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
|
|
local 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 _cached_store_names = {}
|
|
local _store_cache_loaded = 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 function _get_script_opts_dir()
|
|
local dir = nil
|
|
pcall(function()
|
|
dir = mp.command_native({ 'expand-path', '~~/script-opts' })
|
|
end)
|
|
if type(dir) ~= 'string' or dir == '' then
|
|
return nil
|
|
end
|
|
return dir
|
|
end
|
|
|
|
local function _get_selected_store_conf_path()
|
|
local dir = _get_script_opts_dir()
|
|
if not dir then
|
|
return nil
|
|
end
|
|
return utils.join_path(dir, 'medeia.conf')
|
|
end
|
|
|
|
local function _load_selected_store_from_disk()
|
|
local path = _get_selected_store_conf_path()
|
|
if not path then
|
|
return nil
|
|
end
|
|
local fh = io.open(path, 'r')
|
|
if not fh then
|
|
return nil
|
|
end
|
|
for line in fh:lines() do
|
|
local s = trim(tostring(line or ''))
|
|
if s ~= '' and s:sub(1, 1) ~= '#' and s:sub(1, 1) ~= ';' then
|
|
local k, v = s:match('^([%w_%-]+)%s*=%s*(.*)$')
|
|
if k and v and k:lower() == 'store' then
|
|
fh:close()
|
|
v = trim(tostring(v or ''))
|
|
return v ~= '' and v or nil
|
|
end
|
|
end
|
|
end
|
|
fh:close()
|
|
return nil
|
|
end
|
|
|
|
local function _save_selected_store_to_disk(store)
|
|
local path = _get_selected_store_conf_path()
|
|
if not path then
|
|
return false
|
|
end
|
|
local fh = io.open(path, 'w')
|
|
if not fh then
|
|
return false
|
|
end
|
|
fh:write('# Medeia MPV script options\n')
|
|
fh:write('store=' .. tostring(store or '') .. '\n')
|
|
fh:close()
|
|
return true
|
|
end
|
|
|
|
local function _get_selected_store()
|
|
local v = ''
|
|
pcall(function()
|
|
v = tostring(mp.get_property(SELECTED_STORE_PROP) or '')
|
|
end)
|
|
return trim(tostring(v or ''))
|
|
end
|
|
|
|
local function _set_selected_store(store)
|
|
store = trim(tostring(store or ''))
|
|
pcall(mp.set_property, SELECTED_STORE_PROP, store)
|
|
pcall(_save_selected_store_to_disk, store)
|
|
end
|
|
|
|
local function _ensure_selected_store_loaded()
|
|
if _selected_store_loaded then
|
|
return
|
|
end
|
|
_selected_store_loaded = true
|
|
local disk = nil
|
|
pcall(function()
|
|
disk = _load_selected_store_from_disk()
|
|
end)
|
|
disk = trim(tostring(disk or ''))
|
|
if disk ~= '' then
|
|
pcall(mp.set_property, SELECTED_STORE_PROP, disk)
|
|
end
|
|
end
|
|
|
|
local _pipeline_helper_started = false
|
|
local _last_ipc_error = ''
|
|
local _last_ipc_last_req_json = ''
|
|
local _last_ipc_last_resp_json = ''
|
|
|
|
-- Debounce helper start attempts (window in seconds)
|
|
local _helper_start_debounce_ts = 0
|
|
local HELPER_START_DEBOUNCE = 2.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
|
|
return false
|
|
end
|
|
local s = tostring(ready)
|
|
if s == '' or s == '0' then
|
|
return false
|
|
end
|
|
|
|
-- Only support unix timestamp heartbeats from current helper version
|
|
local n = tonumber(s)
|
|
if n and n > 1000000000 then
|
|
local now = (os and os.time) and os.time() or nil
|
|
if not now then
|
|
return true
|
|
end
|
|
local age = now - n
|
|
if age < 0 then
|
|
age = 0
|
|
end
|
|
return age <= 10
|
|
end
|
|
|
|
-- Non-empty value treated as ready
|
|
return true
|
|
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 script_dir = mp.get_script_directory() or utils.getcwd() or ''
|
|
local cli = nil
|
|
pcall(function()
|
|
cli = find_file_upwards(script_dir, 'CLI.py', 8)
|
|
end)
|
|
local cwd = nil
|
|
if cli and cli ~= '' then
|
|
cwd = cli:match('(.*)[/\\]') or nil
|
|
end
|
|
|
|
local args = { python, '-m', 'MPV.pipeline_helper', '--ipc', get_mpv_ipc_path(), '--timeout', '30' }
|
|
_lua_log('attempt_start_pipeline_helper_async: spawning helper')
|
|
|
|
-- Spawn detached; don't wait for it here (async).
|
|
local ok = pcall(mp.command_native, { name = 'subprocess', args = args, cwd = cwd, detach = true })
|
|
if not ok then
|
|
_lua_log('attempt_start_pipeline_helper_async: detached spawn failed, retrying blocking')
|
|
ok = pcall(mp.command_native, { name = 'subprocess', args = args, cwd = cwd })
|
|
end
|
|
|
|
if not ok then
|
|
_lua_log('attempt_start_pipeline_helper_async: spawn failed')
|
|
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 _ipc_async_busy then
|
|
_ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb }
|
|
return
|
|
end
|
|
_ipc_async_busy = true
|
|
|
|
local function done(resp, err)
|
|
_ipc_async_busy = false
|
|
cb(resp, err)
|
|
|
|
if #_ipc_async_queue > 0 then
|
|
local next_job = table.remove(_ipc_async_queue, 1)
|
|
-- Schedule next job slightly later to let mpv deliver any pending events.
|
|
mp.add_timeout(0.01, function()
|
|
_run_helper_request_async(next_job.req, next_job.timeout, next_job.cb)
|
|
end)
|
|
end
|
|
end
|
|
|
|
if type(req) ~= 'table' then
|
|
done(nil, 'invalid request')
|
|
return
|
|
end
|
|
|
|
ensure_mpv_ipc_server()
|
|
if not ensure_pipeline_helper_running() then
|
|
done(nil, 'helper not running')
|
|
return
|
|
end
|
|
|
|
-- Assign id.
|
|
local id = tostring(req.id or '')
|
|
if id == '' then
|
|
id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
|
|
req.id = id
|
|
end
|
|
|
|
local label = ''
|
|
if req.op then
|
|
label = 'op=' .. tostring(req.op)
|
|
elseif req.pipeline then
|
|
label = 'cmd=' .. tostring(req.pipeline)
|
|
else
|
|
label = '(unknown)'
|
|
end
|
|
|
|
-- Wait for helper READY without blocking the UI.
|
|
local ready_deadline = mp.get_time() + 3.0
|
|
local ready_timer
|
|
ready_timer = mp.add_periodic_timer(0.05, function()
|
|
if _is_pipeline_helper_ready() then
|
|
ready_timer:kill()
|
|
|
|
_lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label)
|
|
local req_json = utils.format_json(req)
|
|
_last_ipc_last_req_json = req_json
|
|
|
|
mp.set_property(PIPELINE_RESP_PROP, '')
|
|
mp.set_property(PIPELINE_REQ_PROP, req_json)
|
|
|
|
local deadline = mp.get_time() + (timeout_seconds or 5)
|
|
local poll_timer
|
|
poll_timer = mp.add_periodic_timer(0.05, function()
|
|
if mp.get_time() >= deadline then
|
|
poll_timer:kill()
|
|
done(nil, 'timeout waiting response (' .. label .. ')')
|
|
return
|
|
end
|
|
|
|
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
|
if resp_json and resp_json ~= '' then
|
|
_last_ipc_last_resp_json = resp_json
|
|
local ok, resp = pcall(utils.parse_json, resp_json)
|
|
if ok and resp and resp.id == id then
|
|
poll_timer:kill()
|
|
_lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
|
|
done(resp, nil)
|
|
end
|
|
end
|
|
end)
|
|
|
|
return
|
|
end
|
|
|
|
if mp.get_time() >= ready_deadline then
|
|
ready_timer:kill()
|
|
done(nil, 'helper not ready')
|
|
return
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function _run_helper_request_response(req, timeout_seconds)
|
|
-- Legacy synchronous wrapper for compatibility with run_pipeline_via_ipc_response.
|
|
-- TODO: Migrate all callers to async _run_helper_request_async and remove this.
|
|
_last_ipc_error = ''
|
|
if not ensure_pipeline_helper_running() then
|
|
local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '')
|
|
_lua_log('ipc: helper not ready (ready=' .. rv .. '); attempting request anyway')
|
|
_last_ipc_error = 'helper not ready'
|
|
end
|
|
|
|
do
|
|
-- Best-effort wait for heartbeat, but do not hard-fail the request.
|
|
local deadline = mp.get_time() + 1.5
|
|
while mp.get_time() < deadline do
|
|
if _is_pipeline_helper_ready() then
|
|
break
|
|
end
|
|
mp.wait_event(0.05)
|
|
end
|
|
if not _is_pipeline_helper_ready() then
|
|
local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '')
|
|
_lua_log('ipc: proceeding without helper heartbeat; ready=' .. rv)
|
|
_last_ipc_error = 'helper heartbeat missing (ready=' .. rv .. ')'
|
|
end
|
|
end
|
|
|
|
if type(req) ~= 'table' then
|
|
return nil
|
|
end
|
|
|
|
local id = tostring(req.id or '')
|
|
if id == '' then
|
|
id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
|
|
req.id = id
|
|
end
|
|
|
|
local label = ''
|
|
if req.op then
|
|
label = 'op=' .. tostring(req.op)
|
|
elseif req.pipeline then
|
|
label = 'cmd=' .. tostring(req.pipeline)
|
|
else
|
|
label = '(unknown)'
|
|
end
|
|
_lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label)
|
|
|
|
local req_json = utils.format_json(req)
|
|
_last_ipc_last_req_json = req_json
|
|
mp.set_property(PIPELINE_RESP_PROP, '')
|
|
mp.set_property(PIPELINE_REQ_PROP, req_json)
|
|
-- Read-back for debugging: confirms MPV accepted the property write.
|
|
local echoed = mp.get_property(PIPELINE_REQ_PROP) or ''
|
|
if echoed == '' then
|
|
_lua_log('ipc: WARNING request property echoed empty after set')
|
|
end
|
|
|
|
local deadline = mp.get_time() + (timeout_seconds or 5)
|
|
while mp.get_time() < deadline do
|
|
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
|
|
if resp_json and resp_json ~= '' then
|
|
_last_ipc_last_resp_json = resp_json
|
|
local ok, resp = pcall(utils.parse_json, resp_json)
|
|
if ok and resp and resp.id == id then
|
|
_lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
|
|
return resp
|
|
end
|
|
end
|
|
mp.wait_event(0.05)
|
|
end
|
|
|
|
_lua_log('ipc: timeout waiting response; ' .. label)
|
|
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
|
|
return nil
|
|
end
|
|
|
|
-- IPC helper: return the whole response object (stdout/stderr/error/table)
|
|
local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_seconds)
|
|
local req = { pipeline = pipeline_cmd }
|
|
if seeds then
|
|
req.seeds = seeds
|
|
end
|
|
return _run_helper_request_response(req, timeout_seconds)
|
|
end
|
|
|
|
local function _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)
|
|
-- 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
|
|
-- - success=false: loadfile command failed
|
|
if 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
|
|
|
|
local function _resolve_python_exe(prefer_no_console)
|
|
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
|
|
if (not prefer_no_console) or (not _is_windows()) then
|
|
return python
|
|
end
|
|
|
|
local low = tostring(python):lower()
|
|
if low == 'python' then
|
|
return 'pythonw'
|
|
end
|
|
if low == 'python.exe' then
|
|
return 'pythonw.exe'
|
|
end
|
|
if low:sub(-10) == 'python.exe' then
|
|
local candidate = python:sub(1, #python - 10) .. 'pythonw.exe'
|
|
if utils.file_info(candidate) then
|
|
return candidate
|
|
end
|
|
return 'pythonw'
|
|
end
|
|
-- Already pythonw or some other launcher.
|
|
return 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 _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 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 = mp.get_property('user-data/medeia-config-temp') or os.getenv('TEMP') or os.getenv('TMP') or '/tmp'
|
|
local out_path = utils.join_path(temp_dir, filename)
|
|
|
|
local 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 selected_store = _get_selected_store()
|
|
selected_store = trim(tostring(selected_store or ''))
|
|
selected_store = selected_store:gsub('^\"', ''):gsub('\"$', '')
|
|
|
|
if selected_store == '' then
|
|
mp.osd_message('Select a store first (Store button)', 2)
|
|
return
|
|
end
|
|
|
|
mp.osd_message('Saving screenshot...', 1)
|
|
|
|
-- optimization: use persistent pipeline helper instead of spawning new python process
|
|
-- escape paths for command line
|
|
local cmd = 'add-file -store "' .. selected_store .. '" -path "' .. out_path .. '"'
|
|
|
|
local resp = run_pipeline_via_ipc_response(cmd, nil, 10)
|
|
|
|
if resp and resp.success then
|
|
mp.osd_message('Screenshot saved to store: ' .. selected_store, 3)
|
|
else
|
|
local err = (resp and resp.error) or (resp and resp.stderr) or 'IPC error'
|
|
mp.osd_message('Screenshot upload failed: ' .. tostring(err), 5)
|
|
end
|
|
end
|
|
|
|
mp.register_script_message('medeia-image-screenshot', function()
|
|
_capture_screenshot()
|
|
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 _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
|
|
|
|
local function _refresh_store_cache(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
|
|
|
|
_lua_log('stores: requesting store-choices via helper (fallback)')
|
|
_run_helper_request_async({ op = 'store-choices' }, timeout_seconds or 1, 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
|
|
|
|
local function _uosc_open_list_picker(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 items = {}
|
|
|
|
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
|
for _, name in ipairs(_cached_store_names) do
|
|
name = trim(tostring(name or ''))
|
|
if name ~= '' then
|
|
local payload = { store = name }
|
|
items[#items + 1] = {
|
|
title = name,
|
|
active = (selected ~= '' and name == selected) and true or false,
|
|
value = { 'script-message-to', mp.get_script_name(), 'medeia-store-select', utils.format_json(payload) },
|
|
}
|
|
end
|
|
end
|
|
else
|
|
items[#items + 1] = {
|
|
title = 'No stores found',
|
|
hint = 'Configure stores in config.conf',
|
|
selectable = false,
|
|
}
|
|
end
|
|
|
|
return items
|
|
end
|
|
|
|
-- Open immediately with whatever cache we have.
|
|
_uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())
|
|
|
|
-- Best-effort refresh; retry briefly to avoid races where the helper isn't
|
|
-- ready/observing yet at the exact moment the menu opens.
|
|
local function attempt_refresh(tries_left)
|
|
_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)
|
|
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: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.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 _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 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
|
|
|
|
local function _get_cached_formats_table(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
|
|
|
|
function FileState:fetch_formats(cb)
|
|
local url = tostring(self.url or '')
|
|
if url == '' or not _is_http_url(url) then
|
|
if cb then cb(false, 'not a url') end
|
|
return
|
|
end
|
|
|
|
if _extract_store_hash(url) then
|
|
if cb then cb(false, 'store-hash url') end
|
|
return
|
|
end
|
|
|
|
local cached = _get_cached_formats_table(url)
|
|
if type(cached) == 'table' then
|
|
self:set_formats(url, cached)
|
|
if cb then cb(true, nil) end
|
|
return
|
|
end
|
|
|
|
if _formats_inflight[url] then
|
|
_formats_waiters[url] = _formats_waiters[url] or {}
|
|
if cb then table.insert(_formats_waiters[url], cb) end
|
|
return
|
|
end
|
|
_formats_inflight[url] = true
|
|
_formats_waiters[url] = _formats_waiters[url] or {}
|
|
if cb then table.insert(_formats_waiters[url], cb) end
|
|
|
|
_run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, 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')
|
|
else
|
|
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 {}
|
|
_formats_waiters[url] = nil
|
|
for _, fn in ipairs(waiters) do
|
|
pcall(fn, ok, reason)
|
|
end
|
|
end)
|
|
end
|
|
|
|
local function _prefetch_formats_for_url(url)
|
|
url = tostring(url or '')
|
|
if url == '' or not _is_http_url(url) then
|
|
return
|
|
end
|
|
if type(M.file) == 'table' then
|
|
M.file.url = url
|
|
if M.file.fetch_formats then
|
|
M.file:fetch_formats(nil)
|
|
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
|
|
|
|
local function _debug_dump_formatted_formats(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
|
|
|
|
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 _run_pipeline_detached(pipeline_cmd, on_failure)
|
|
if not pipeline_cmd or pipeline_cmd == '' then
|
|
return false
|
|
end
|
|
ensure_mpv_ipc_server()
|
|
if not ensure_pipeline_helper_running() then
|
|
if type(on_failure) == 'function' then
|
|
on_failure(nil, 'helper not running')
|
|
end
|
|
return false
|
|
end
|
|
_run_helper_request_async({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0, function(resp, err)
|
|
if resp and resp.success then
|
|
return
|
|
end
|
|
if type(on_failure) == 'function' then
|
|
on_failure(resp, err)
|
|
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 function build_items()
|
|
local items = {
|
|
{
|
|
title = 'Pick folder…',
|
|
hint = '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 }
|
|
items[#items + 1] = {
|
|
title = name,
|
|
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, 'Save location', 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, 'Save location', 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
|
|
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
|
|
|
|
_lua_log('change-format: setting options/ytdl-format=' .. tostring(fmt))
|
|
pcall(mp.set_property, 'options/ytdl-format', tostring(fmt))
|
|
|
|
if pos and pos > 0 then
|
|
mp.commandv('loadfile', url, 'replace', 'start=' .. tostring(pos))
|
|
else
|
|
mp.commandv('loadfile', url, 'replace')
|
|
end
|
|
|
|
if paused then
|
|
mp.set_property_native('pause', true)
|
|
end
|
|
end
|
|
|
|
local function _start_download_flow_for_current()
|
|
local target = _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)
|
|
M.run_pipeline(pipeline_cmd, nil, function(_, err)
|
|
if err then
|
|
mp.osd_message('Download failed: ' .. tostring(err), 5)
|
|
end
|
|
end)
|
|
mp.osd_message('Download started', 2)
|
|
return
|
|
end
|
|
|
|
-- Non-store URL flow: use the current yt-dlp-selected format and ask for save location.
|
|
local url = tostring(target)
|
|
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))
|
|
_pending_download = { url = url, format = fmt }
|
|
_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_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)
|
|
|
|
-- Ensure file state is tracking the current URL.
|
|
if type(M.file) == 'table' then
|
|
M.file.url = url
|
|
end
|
|
|
|
-- If formats were already prefetched for this URL, open instantly.
|
|
local cached_tbl = nil
|
|
if type(M.file) == 'table' and type(M.file.formats) == 'table' then
|
|
cached_tbl = M.file.formats
|
|
else
|
|
cached_tbl = _get_cached_formats_table(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 }
|
|
|
|
local items = {}
|
|
for idx, row in ipairs(cached_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
|
|
|
|
_debug_dump_formatted_formats(url, cached_tbl, items)
|
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
|
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
|
|
|
|
local items = {}
|
|
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
|
|
|
|
_pending_format_change.formats_table = tbl
|
|
_debug_dump_formatted_formats(url, tbl, items)
|
|
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
|
|
end)
|
|
end
|
|
end)
|
|
|
|
-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.
|
|
mp.register_event('file-loaded', function()
|
|
local target = _current_target()
|
|
if not target or target == '' then
|
|
return
|
|
end
|
|
local url = tostring(target)
|
|
if not _is_http_url(url) then
|
|
return
|
|
end
|
|
_prefetch_formats_for_url(url)
|
|
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 pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt)
|
|
.. ' | add-file -store ' .. quote_pipeline_arg(store)
|
|
|
|
local function run_pipeline_direct()
|
|
M.run_pipeline(pipeline_cmd, nil, function(_, err)
|
|
if err then
|
|
mp.osd_message('Download failed: ' .. tostring(err), 5)
|
|
end
|
|
end)
|
|
end
|
|
|
|
if not _run_pipeline_detached(pipeline_cmd, function()
|
|
run_pipeline_direct()
|
|
end) then
|
|
run_pipeline_direct()
|
|
end
|
|
mp.osd_message('Download started', 3)
|
|
_pending_download = nil
|
|
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 pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt)
|
|
.. ' | add-file -path ' .. quote_pipeline_arg(folder)
|
|
|
|
local function run_pipeline_direct()
|
|
M.run_pipeline(pipeline_cmd, nil, function(_, err)
|
|
if err then
|
|
mp.osd_message('Download failed: ' .. tostring(err), 5)
|
|
end
|
|
end)
|
|
end
|
|
|
|
if not _run_pipeline_detached(pipeline_cmd, function()
|
|
run_pipeline_direct()
|
|
end) then
|
|
run_pipeline_direct()
|
|
end
|
|
mp.osd_message('Download started', 3)
|
|
_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: <repo>/MPV/LUA/main.lua, and <repo>/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 longer timeout for .mpv -url commands since they may involve downloading
|
|
local timeout_seconds = pipeline_cmd:match('%.mpv%s+%-url') and 120 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
|
|
if not pipeline_cmd:match('output%-json$') then
|
|
pipeline_cmd = pipeline_cmd .. ' | output-json'
|
|
end
|
|
M.run_pipeline(pipeline_cmd, seeds, function(output, err)
|
|
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, err)
|
|
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)
|
|
|
|
-- Load the trim module for direct FFmpeg trimming
|
|
local script_dir = mp.get_script_directory()
|
|
_lua_log('trim: script_dir=' .. tostring(script_dir))
|
|
|
|
-- Try multiple locations for trim.lua
|
|
local trim_paths = {}
|
|
if script_dir and script_dir ~= '' then
|
|
table.insert(trim_paths, script_dir .. '/trim.lua')
|
|
table.insert(trim_paths, script_dir .. '/LUA/trim.lua') -- if called from parent
|
|
table.insert(trim_paths, script_dir .. '/../trim.lua')
|
|
end
|
|
|
|
-- Also try absolute path
|
|
table.insert(trim_paths, '/medios/Medios-Macina/MPV/LUA/trim.lua')
|
|
table.insert(trim_paths, 'C:/medios/Medios-Macina/MPV/LUA/trim.lua')
|
|
|
|
local trim_module = nil
|
|
local load_err = nil
|
|
|
|
for _, trim_path in ipairs(trim_paths) do
|
|
_lua_log('trim: trying path=' .. trim_path)
|
|
local ok, result = pcall(loadfile, trim_path)
|
|
if ok and result then
|
|
trim_module = result()
|
|
_lua_log('trim: loaded successfully from ' .. trim_path)
|
|
break
|
|
else
|
|
load_err = tostring(result or 'unknown error')
|
|
_lua_log('trim: failed to load from ' .. trim_path .. ' (' .. load_err .. ')')
|
|
end
|
|
end
|
|
|
|
if not trim_module or not trim_module.trim_file then
|
|
mp.osd_message('ERROR: Could not load trim module from any path', 3)
|
|
_lua_log('trim: FAILED - all paths exhausted, last error=' .. tostring(load_err))
|
|
return
|
|
end
|
|
|
|
range = trim(tostring(range or ''))
|
|
_lua_log('trim: after_trim range=' .. tostring(range))
|
|
|
|
if range == '' then
|
|
mp.osd_message('Trim cancelled (no range provided)', 3)
|
|
_lua_log('trim: CANCELLED - empty range')
|
|
return
|
|
end
|
|
|
|
local target = _current_target()
|
|
if not target or target == '' then
|
|
mp.osd_message('No file to trim', 3)
|
|
_lua_log('trim: FAILED - no target')
|
|
return
|
|
end
|
|
_lua_log('trim: target=' .. tostring(target))
|
|
|
|
local store_hash = _extract_store_hash(target)
|
|
if store_hash then
|
|
_lua_log('trim: store_hash detected store=' .. tostring(store_hash.store) .. ' hash=' .. tostring(store_hash.hash))
|
|
else
|
|
_lua_log('trim: store_hash=nil (local file)')
|
|
end
|
|
|
|
-- Get the selected store (this reads from saved config or mpv property)
|
|
_ensure_selected_store_loaded()
|
|
local selected_store = _get_selected_store()
|
|
-- Strip any existing quotes from the store name
|
|
selected_store = selected_store:gsub('^"', ''):gsub('"$', '')
|
|
_lua_log('trim: selected_store=' .. tostring(selected_store or 'NONE'))
|
|
_lua_log('trim: _cached_store_names=' .. tostring(_cached_store_names and #_cached_store_names or 0))
|
|
_lua_log('trim: _selected_store_index=' .. tostring(_selected_store_index or 'nil'))
|
|
|
|
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 ===')
|
|
|
|
-- Optimization: use persistent pipeline helper
|
|
local response = run_pipeline_via_ipc_response(pipeline_cmd, nil, 60)
|
|
|
|
if not response then
|
|
response = { success = false, error = "Timeout or IPC error" }
|
|
end
|
|
|
|
_lua_log('trim: api response success=' .. tostring(response.success))
|
|
_lua_log('trim: api response error=' .. tostring(response.error or 'nil'))
|
|
_lua_log('trim: api response stderr=' .. tostring(response.stderr or 'nil'))
|
|
_lua_log('trim: api response returncode=' .. tostring(response.returncode or 'nil'))
|
|
|
|
if response.stderr and response.stderr ~= '' then
|
|
_lua_log('trim: STDERR OUTPUT: ' .. response.stderr)
|
|
end
|
|
|
|
if response.success then
|
|
local msg = 'Trim and upload completed'
|
|
if selected_store then
|
|
msg = msg .. ' (store: ' .. selected_store .. ')'
|
|
end
|
|
mp.osd_message(msg, 5)
|
|
_lua_log('trim: SUCCESS - ' .. msg)
|
|
else
|
|
local err_msg = response.error or response.stderr or 'unknown error'
|
|
mp.osd_message('Upload failed: ' .. err_msg, 5)
|
|
_lua_log('trim: upload FAILED - ' .. err_msg)
|
|
end
|
|
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')
|
|
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)
|
|
_lua_log('[LOAD-URL] Starting to load: ' .. 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
|
|
|
|
-- First, always try direct loadfile. This is the fastest path.
|
|
local can_direct = _url_can_direct_load(url)
|
|
_lua_log('[LOAD-URL] Checking if URL can be loaded directly: ' .. tostring(can_direct))
|
|
|
|
if can_direct then
|
|
_lua_log('[LOAD-URL] Attempting direct loadfile')
|
|
local ok_load = pcall(mp.commandv, 'loadfile', url, 'replace')
|
|
if ok_load then
|
|
_lua_log('[LOAD-URL] Direct loadfile command sent successfully')
|
|
mp.osd_message('URL loaded', 2)
|
|
close_menu()
|
|
return
|
|
else
|
|
_lua_log('[LOAD-URL] Direct loadfile command failed')
|
|
mp.osd_message('Load URL failed (direct)', 3)
|
|
close_menu()
|
|
return
|
|
end
|
|
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))
|
|
|
|
if not helper_ready then
|
|
_lua_log('[LOAD-URL] Pipeline helper not available')
|
|
mp.osd_message('Pipeline helper not running (try menu again)', 3)
|
|
close_menu()
|
|
return
|
|
end
|
|
|
|
-- 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)
|
|
M.run_pipeline(pipeline_cmd, nil, function(resp, err)
|
|
_lua_log('[LOAD-URL] Pipeline callback received: resp=' .. tostring(resp) .. ', err=' .. tostring(err))
|
|
if err then
|
|
_lua_log('[LOAD-URL] Pipeline error: ' .. tostring(err))
|
|
mp.osd_message('Load URL failed: ' .. tostring(err), 3)
|
|
close_menu()
|
|
return
|
|
end
|
|
_lua_log('[LOAD-URL] URL loaded successfully')
|
|
mp.osd_message('URL loaded', 2)
|
|
close_menu()
|
|
end)
|
|
end)
|
|
|
|
-- Menu integration with UOSC
|
|
function M.show_menu()
|
|
_lua_log('[MENU] M.show_menu called')
|
|
|
|
-- 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" },
|
|
{ title = "Change Format", value = "script-message medios-change-format-current" },
|
|
}
|
|
|
|
-- Conditionally add "Start Helper" if not running
|
|
if not _is_pipeline_helper_ready() then
|
|
table.insert(items, { title = "Start Helper", hint = "(pipeline actions)", value = "script-message medios-start-helper" })
|
|
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)
|
|
|
|
-- 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)
|
|
|
|
-- Load optional modules (kept in separate files).
|
|
pcall(function()
|
|
local script_dir = mp.get_script_directory() or ''
|
|
local candidates = {}
|
|
if script_dir ~= '' then
|
|
table.insert(candidates, script_dir .. '/sleep_timer.lua')
|
|
table.insert(candidates, script_dir .. '/LUA/sleep_timer.lua')
|
|
table.insert(candidates, script_dir .. '/../sleep_timer.lua')
|
|
end
|
|
table.insert(candidates, 'C:/medios/Medios-Macina/MPV/LUA/sleep_timer.lua')
|
|
for _, p in ipairs(candidates) do
|
|
local ok, chunk = pcall(loadfile, p)
|
|
if ok and chunk then
|
|
pcall(chunk)
|
|
break
|
|
end
|
|
end
|
|
end)
|
|
end)
|
|
|
|
return M
|