2658 lines
89 KiB
Lua
2658 lines
89 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
|
|
}
|
|
|
|
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
|
|
|
|
local SELECTED_STORE_PROP = 'user-data/medeia-selected-store'
|
|
local STORE_PICKER_MENU_TYPE = 'medeia_store_picker'
|
|
local _selected_store_loaded = false
|
|
|
|
local function _get_script_opts_dir()
|
|
local dir = nil
|
|
pcall(function()
|
|
dir = mp.command_native({ 'expand-path', '~~/script-opts' })
|
|
end)
|
|
if type(dir) ~= 'string' or dir == '' then
|
|
return nil
|
|
end
|
|
return dir
|
|
end
|
|
|
|
local function _get_selected_store_conf_path()
|
|
local dir = _get_script_opts_dir()
|
|
if not dir then
|
|
return nil
|
|
end
|
|
return utils.join_path(dir, 'medeia.conf')
|
|
end
|
|
|
|
local function _load_selected_store_from_disk()
|
|
local path = _get_selected_store_conf_path()
|
|
if not path then
|
|
return nil
|
|
end
|
|
local fh = io.open(path, 'r')
|
|
if not fh then
|
|
return nil
|
|
end
|
|
for line in fh:lines() do
|
|
local s = trim(tostring(line or ''))
|
|
if s ~= '' and s:sub(1, 1) ~= '#' and s:sub(1, 1) ~= ';' then
|
|
local k, v = s:match('^([%w_%-]+)%s*=%s*(.*)$')
|
|
if k and v and k:lower() == 'store' then
|
|
fh:close()
|
|
v = trim(tostring(v or ''))
|
|
return v ~= '' and v or nil
|
|
end
|
|
end
|
|
end
|
|
fh:close()
|
|
return nil
|
|
end
|
|
|
|
local function _save_selected_store_to_disk(store)
|
|
local path = _get_selected_store_conf_path()
|
|
if not path then
|
|
return false
|
|
end
|
|
local fh = io.open(path, 'w')
|
|
if not fh then
|
|
return false
|
|
end
|
|
fh:write('# Medeia MPV script options\n')
|
|
fh:write('store=' .. tostring(store or '') .. '\n')
|
|
fh:close()
|
|
return true
|
|
end
|
|
|
|
local function _get_selected_store()
|
|
local v = ''
|
|
pcall(function()
|
|
v = tostring(mp.get_property(SELECTED_STORE_PROP) or '')
|
|
end)
|
|
return trim(tostring(v or ''))
|
|
end
|
|
|
|
local function _set_selected_store(store)
|
|
store = trim(tostring(store or ''))
|
|
pcall(mp.set_property, SELECTED_STORE_PROP, store)
|
|
pcall(_save_selected_store_to_disk, store)
|
|
end
|
|
|
|
local function _ensure_selected_store_loaded()
|
|
if _selected_store_loaded then
|
|
return
|
|
end
|
|
_selected_store_loaded = true
|
|
local disk = nil
|
|
pcall(function()
|
|
disk = _load_selected_store_from_disk()
|
|
end)
|
|
disk = trim(tostring(disk or ''))
|
|
if disk ~= '' then
|
|
pcall(mp.set_property, SELECTED_STORE_PROP, disk)
|
|
end
|
|
end
|
|
|
|
local _pipeline_helper_started = false
|
|
local _last_ipc_error = ''
|
|
local _last_ipc_last_req_json = ''
|
|
local _last_ipc_last_resp_json = ''
|
|
|
|
local function _is_pipeline_helper_ready()
|
|
local ready = mp.get_property(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
|
|
|
|
-- Back-compat: older helpers may set "1". New helpers set unix timestamps.
|
|
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
|
|
|
|
-- If it's some other non-empty value, treat 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-medeia-macina'
|
|
end
|
|
return '/tmp/mpv-medeia-macina.sock'
|
|
end
|
|
|
|
local function ensure_mpv_ipc_server()
|
|
-- `.pipe -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 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 ok = pcall(function()
|
|
mp.commandv('screenshot-to-file', out_path, 'video')
|
|
end)
|
|
if not ok then
|
|
mp.osd_message('Screenshot failed', 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
|
|
|
|
local python_exe = _resolve_python_exe(true)
|
|
if not python_exe or python_exe == '' then
|
|
mp.osd_message('Screenshot saved; Python not found', 3)
|
|
return
|
|
end
|
|
|
|
local start_dir = mp.get_script_directory() or ''
|
|
local cli_py = find_file_upwards(start_dir, 'CLI.py', 8)
|
|
if not cli_py or cli_py == '' or not utils.file_info(cli_py) then
|
|
mp.osd_message('Screenshot saved; CLI.py not found', 3)
|
|
return
|
|
end
|
|
|
|
local res = utils.subprocess({
|
|
args = { python_exe, cli_py, 'add-file', '-store', selected_store, '-path', out_path },
|
|
cancellable = false,
|
|
})
|
|
|
|
if res and res.status == 0 then
|
|
mp.osd_message('Screenshot saved to store: ' .. selected_store, 3)
|
|
else
|
|
local stderr = (res and res.stderr) or 'unknown error'
|
|
mp.osd_message('Screenshot upload failed: ' .. tostring(stderr), 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
|
|
|
|
-- Forward declaration: used by run_pipeline_via_ipc_response before definition.
|
|
local ensure_pipeline_helper_running
|
|
|
|
local function _run_helper_request_response(req, timeout_seconds)
|
|
_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 _refresh_store_cache(timeout_seconds)
|
|
ensure_mpv_ipc_server()
|
|
|
|
-- First, try reading the pre-computed cached property (set by helper at startup).
|
|
-- This avoids a request/response timeout if observe_property isn't working.
|
|
local cached_json = mp.get_property('user-data/medeia-store-choices-cached')
|
|
_lua_log('stores: cache_read cached_json=' .. tostring(cached_json) .. ' len=' .. tostring(cached_json and #cached_json or 0))
|
|
|
|
if cached_json and cached_json ~= '' then
|
|
-- Try to parse as JSON (may fail if not valid JSON)
|
|
local ok, cached_resp = pcall(utils.parse_json, cached_json)
|
|
_lua_log('stores: cache_parse ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
|
|
|
|
-- Handle both cases: parsed object OR string (if JSON lib returns string)
|
|
if ok then
|
|
-- If parse returned a string, it might still be valid JSON; try parsing again
|
|
if type(cached_resp) == 'string' then
|
|
_lua_log('stores: cache_parse returned string, trying again...')
|
|
ok, cached_resp = pcall(utils.parse_json, cached_resp)
|
|
_lua_log('stores: cache_parse retry ok=' .. tostring(ok) .. ' resp_type=' .. tostring(type(cached_resp)))
|
|
end
|
|
|
|
-- Now check if we have a table with choices
|
|
if type(cached_resp) == 'table' and type(cached_resp.choices) == 'table' then
|
|
local out = {}
|
|
for _, v in ipairs(cached_resp.choices) do
|
|
local name = trim(tostring(v or ''))
|
|
if name ~= '' then
|
|
out[#out + 1] = name
|
|
end
|
|
end
|
|
_cached_store_names = out
|
|
_store_cache_loaded = true
|
|
local preview = ''
|
|
if #_cached_store_names > 0 then
|
|
preview = table.concat(_cached_store_names, ', ')
|
|
end
|
|
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores from cache: ' .. tostring(preview))
|
|
return true
|
|
else
|
|
_lua_log('stores: cache_parse final type mismatch resp_type=' .. tostring(type(cached_resp)) .. ' choices_type=' .. tostring(cached_resp and type(cached_resp.choices) or 'n/a'))
|
|
end
|
|
else
|
|
_lua_log('stores: cache_parse failed ok=' .. tostring(ok) .. ' resp=' .. tostring(cached_resp))
|
|
end
|
|
else
|
|
_lua_log('stores: cache_empty cached_json=' .. tostring(cached_json))
|
|
end
|
|
|
|
-- Fallback: request fresh store-choices from helper (with timeout).
|
|
_lua_log('stores: requesting store-choices via helper (fallback)')
|
|
local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1)
|
|
if not resp or not resp.success or type(resp.choices) ~= 'table' then
|
|
_lua_log(
|
|
'stores: failed to load store choices via helper; success='
|
|
.. tostring(resp and resp.success or false)
|
|
.. ' choices_type='
|
|
.. tostring(resp and type(resp.choices) or 'nil')
|
|
.. ' stderr='
|
|
.. tostring(resp and resp.stderr or '')
|
|
.. ' error='
|
|
.. tostring(resp and resp.error or '')
|
|
)
|
|
return false
|
|
end
|
|
|
|
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 #_cached_store_names > 0 then
|
|
preview = table.concat(_cached_store_names, ', ')
|
|
end
|
|
_lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via helper request: ' .. tostring(preview))
|
|
return true
|
|
end
|
|
|
|
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)
|
|
local before_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
|
local before_preview = ''
|
|
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
|
before_preview = table.concat(_cached_store_names, ', ')
|
|
end
|
|
|
|
local ok = _refresh_store_cache(1.2)
|
|
local after_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
|
local after_preview = ''
|
|
if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
|
|
after_preview = table.concat(_cached_store_names, ', ')
|
|
end
|
|
|
|
_lua_log(
|
|
'stores: refresh attempt ok='
|
|
.. tostring(ok)
|
|
.. ' before='
|
|
.. tostring(before_count)
|
|
.. ' after='
|
|
.. tostring(after_count)
|
|
.. ' after='
|
|
.. tostring(after_preview)
|
|
)
|
|
|
|
if after_count > 0 and (after_count ~= before_count or after_preview ~= before_preview) then
|
|
_lua_log('stores: reopening menu (store list changed)')
|
|
_uosc_open_list_picker(STORE_PICKER_MENU_TYPE, 'Store', build_items())
|
|
return
|
|
end
|
|
|
|
if tries_left > 0 then
|
|
mp.add_timeout(0.25, function()
|
|
attempt_refresh(tries_left - 1)
|
|
end)
|
|
end
|
|
end
|
|
|
|
mp.add_timeout(0.05, function()
|
|
attempt_refresh(6)
|
|
end)
|
|
end
|
|
|
|
mp.register_script_message('medeia-store-picker', function()
|
|
_open_store_picker()
|
|
end)
|
|
|
|
mp.register_script_message('medeia-store-select', function(json)
|
|
local ok, ev = pcall(utils.parse_json, json)
|
|
if not ok or type(ev) ~= 'table' then
|
|
return
|
|
end
|
|
local store = trim(tostring(ev.store or ''))
|
|
if store == '' then
|
|
return
|
|
end
|
|
_set_selected_store(store)
|
|
mp.osd_message('Store: ' .. store, 2)
|
|
end)
|
|
|
|
-- No-op handler for placeholder menu items.
|
|
mp.register_script_message('medios-nop', function()
|
|
return
|
|
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,
|
|
formats_table = nil, -- back-compat alias
|
|
}, 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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
-- Only applies to plain URLs (not store hash URLs).
|
|
if _extract_store_hash(url) then
|
|
if cb then cb(false, 'store-hash url') end
|
|
return
|
|
end
|
|
|
|
-- Cache hit.
|
|
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
|
|
|
|
-- In-flight: register waiter.
|
|
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
|
|
|
|
-- Async request so the UI never blocks.
|
|
_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)
|
|
if not pipeline_cmd or pipeline_cmd == '' then
|
|
return false
|
|
end
|
|
local resp = _run_helper_request_response({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0)
|
|
return (resp and resp.success) and true or false
|
|
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
|
|
local before = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
|
if _refresh_store_cache(1.5) then
|
|
local after = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
|
|
if after > 0 and after ~= before 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()
|
|
M.run_pipeline('get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder))
|
|
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)
|
|
|
|
if not _run_pipeline_detached(pipeline_cmd) then
|
|
-- Fall back to synchronous execution if detached failed.
|
|
M.run_pipeline(pipeline_cmd)
|
|
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)
|
|
|
|
if not _run_pipeline_detached(pipeline_cmd) then
|
|
M.run_pipeline(pipeline_cmd)
|
|
end
|
|
mp.osd_message('Download started', 3)
|
|
_pending_download = nil
|
|
end)
|
|
|
|
ensure_pipeline_helper_running = function()
|
|
-- IMPORTANT: do NOT spawn Python from inside mpv.
|
|
-- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows
|
|
-- no-console flags; spawning here can flash a console window.
|
|
return _is_pipeline_helper_ready() and true or false
|
|
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
|
|
|
|
-- Clean API wrapper for executing Python functions from Lua
|
|
local function _call_mpv_api(request)
|
|
-- Call the MPV Lua API (mpv_lua_api.py) with a JSON request.
|
|
-- Returns: JSON-decoded response object with {success, stdout, stderr, error, ...}
|
|
local request_json = utils.format_json(request)
|
|
|
|
-- Try to get log file path; skip if not available
|
|
local log_file = ''
|
|
local home = os.getenv('USERPROFILE') or os.getenv('HOME') or ''
|
|
if home ~= '' then
|
|
log_file = home .. '/../../medios/Medios-Macina/Log/medeia-mpv-helper.log'
|
|
end
|
|
|
|
_lua_log('api: calling mpv_lua_api cmd=' .. tostring(request.cmd))
|
|
|
|
local python_exe = _resolve_python_exe(true)
|
|
if not python_exe or python_exe == '' then
|
|
_lua_log('api: FAILED - no python exe')
|
|
return { success = false, error = 'could not find Python' }
|
|
end
|
|
|
|
-- Try to locate API script
|
|
local api_script = nil
|
|
local script_dir = mp.get_script_directory()
|
|
if script_dir and script_dir ~= '' then
|
|
api_script = script_dir .. '/mpv_lua_api.py'
|
|
if not utils.file_info(api_script) then
|
|
api_script = script_dir .. '/../mpv_lua_api.py'
|
|
end
|
|
end
|
|
|
|
if not api_script or api_script == '' or not utils.file_info(api_script) then
|
|
-- Fallback: try absolute path
|
|
local repo_root = os.getenv('USERPROFILE')
|
|
if repo_root then
|
|
api_script = repo_root .. '/../../../medios/Medios-Macina/MPV/mpv_lua_api.py'
|
|
end
|
|
end
|
|
|
|
if not api_script or api_script == '' then
|
|
_lua_log('api: FAILED - could not locate mpv_lua_api.py')
|
|
return { success = false, error = 'could not locate mpv_lua_api.py' }
|
|
end
|
|
|
|
_lua_log('api: python=' .. tostring(python_exe) .. ' script=' .. tostring(api_script))
|
|
|
|
local res = utils.subprocess({
|
|
args = { python_exe, api_script, request_json, log_file },
|
|
cancellable = false,
|
|
})
|
|
|
|
if res and res.status == 0 and res.stdout then
|
|
local ok, response = pcall(utils.parse_json, res.stdout)
|
|
if ok and response then
|
|
_lua_log('api: response success=' .. tostring(response.success))
|
|
return response
|
|
else
|
|
_lua_log('api: failed to parse response: ' .. tostring(res.stdout))
|
|
return { success = false, error = 'malformed response', stdout = res.stdout }
|
|
end
|
|
else
|
|
local stderr = res and res.stderr or 'unknown error'
|
|
_lua_log('api: subprocess failed status=' .. tostring(res and res.status or 'nil') .. ' stderr=' .. stderr)
|
|
return { success = false, error = stderr }
|
|
end
|
|
end
|
|
|
|
-- Helper to run pipeline and parse JSON output
|
|
function M.run_pipeline_json(pipeline_cmd, seeds)
|
|
-- Append | output-json if not present
|
|
if not pipeline_cmd:match("output%-json$") then
|
|
pipeline_cmd = pipeline_cmd .. " | output-json"
|
|
end
|
|
|
|
local output = M.run_pipeline(pipeline_cmd, seeds)
|
|
if output then
|
|
local ok, data = pcall(utils.parse_json, output)
|
|
if ok then
|
|
return data
|
|
else
|
|
_lua_log("Failed to parse JSON: " .. output)
|
|
return nil
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Command: Get info for current file
|
|
function M.get_file_info()
|
|
local path = mp.get_property("path")
|
|
if not path then return end
|
|
|
|
-- We can pass the path as a seed item
|
|
local seed = {{path = path}}
|
|
|
|
-- Run pipeline: get-metadata
|
|
local data = M.run_pipeline_json("get-metadata", seed)
|
|
|
|
if data then
|
|
-- Display metadata
|
|
_lua_log("Metadata: " .. utils.format_json(data))
|
|
mp.osd_message("Metadata loaded (check console)", 3)
|
|
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)
|
|
mp.osd_message("File deleted", 3)
|
|
mp.command("playlist-next")
|
|
end
|
|
|
|
-- Command: Load a URL via pipeline (Ctrl+Enter in prompt)
|
|
function M.open_load_url_prompt()
|
|
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
|
|
mp.commandv('script-message-to', 'uosc', 'open-menu', json)
|
|
else
|
|
_lua_log('menu: uosc not available; cannot open-menu')
|
|
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 API FOR UPLOAD ===')
|
|
|
|
-- Call the API to handle metadata/storage
|
|
local response = _call_mpv_api({
|
|
cmd = 'execute_pipeline',
|
|
pipeline = pipeline_cmd,
|
|
})
|
|
|
|
_lua_log('trim: api response success=' .. tostring(response.success))
|
|
_lua_log('trim: api response error=' .. tostring(response.error or 'nil'))
|
|
_lua_log('trim: api response stderr=' .. tostring(response.stderr or 'nil'))
|
|
_lua_log('trim: api response returncode=' .. tostring(response.returncode or 'nil'))
|
|
|
|
if response.stderr and response.stderr ~= '' then
|
|
_lua_log('trim: STDERR OUTPUT: ' .. response.stderr)
|
|
end
|
|
|
|
if response.success then
|
|
local msg = 'Trim and upload completed'
|
|
if selected_store then
|
|
msg = msg .. ' (store: ' .. selected_store .. ')'
|
|
end
|
|
mp.osd_message(msg, 5)
|
|
_lua_log('trim: SUCCESS - ' .. msg)
|
|
else
|
|
local err_msg = response.error or response.stderr or 'unknown error'
|
|
mp.osd_message('Upload failed: ' .. err_msg, 5)
|
|
_lua_log('trim: upload FAILED - ' .. err_msg)
|
|
end
|
|
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()
|
|
M.open_load_url_prompt()
|
|
end)
|
|
|
|
mp.register_script_message('medios-load-url-event', function(json)
|
|
local ok, event = pcall(utils.parse_json, json)
|
|
if not ok or type(event) ~= 'table' then
|
|
return
|
|
end
|
|
if event.type ~= 'search' then
|
|
return
|
|
end
|
|
|
|
local url = trim(tostring(event.query or ''))
|
|
if url == '' then
|
|
return
|
|
end
|
|
|
|
ensure_mpv_ipc_server()
|
|
local out = M.run_pipeline('.pipe -url ' .. quote_pipeline_arg(url) .. ' -play')
|
|
if out ~= nil then
|
|
if ensure_uosc_loaded() then
|
|
mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE)
|
|
else
|
|
_lua_log('menu: uosc not available; cannot close-menu')
|
|
end
|
|
end
|
|
end)
|
|
|
|
-- Menu integration with UOSC
|
|
function M.show_menu()
|
|
local menu_data = {
|
|
title = "Medios Macina",
|
|
items = {
|
|
{ title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" },
|
|
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
|
|
{ title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} },
|
|
{ title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" },
|
|
{ title = "Download", value = {"script-message-to", mp.get_script_name(), "medios-download-current"} },
|
|
{ title = "Change Format", value = {"script-message-to", mp.get_script_name(), "medios-change-format-current"} },
|
|
}
|
|
}
|
|
|
|
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-menu')
|
|
end
|
|
end
|
|
|
|
-- Keybindings
|
|
mp.add_key_binding("m", "medios-menu", M.show_menu)
|
|
mp.add_key_binding("mbtn_right", "medios-menu-right-click", M.show_menu)
|
|
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)
|
|
|
|
|
|
|
|
-- 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)
|
|
|
|
-- 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
|