Files
Medios-Macina/MPV/LUA/main.lua

2658 lines
89 KiB
Lua
Raw Normal View History

2025-11-27 10:59:01 -08:00
local mp = require 'mp'
local utils = require 'mp.utils'
local msg = require 'mp.msg'
local M = {}
2025-12-24 02:13:21 -08:00
local MEDEIA_LUA_VERSION = '2025-12-24'
2025-12-18 22:50:21 -08:00
2025-12-27 03:13:16 -08:00
-- Expose a tiny breadcrumb for debugging which script version is loaded.
pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION)
2025-12-18 22:50:21 -08:00
-- 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
2025-12-17 17:42:46 -08:00
local LOAD_URL_MENU_TYPE = 'medios_load_url'
2025-12-18 22:50:21 -08:00
local DOWNLOAD_FORMAT_MENU_TYPE = 'medios_download_pick_format'
local DOWNLOAD_STORE_MENU_TYPE = 'medios_download_pick_store'
2025-12-23 16:36:39 -08:00
-- Menu types for the command submenu and trim prompt
local CMD_MENU_TYPE = 'medios_cmd_menu'
local TRIM_PROMPT_MENU_TYPE = 'medios_trim_prompt'
2025-12-17 17:42:46 -08:00
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'
2025-12-18 22:50:21 -08:00
-- 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 = ''
2025-12-27 03:13:16 -08:00
-- 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
2025-12-18 22:50:21 -08:00
-- 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
2025-12-27 03:13:16 -08:00
_lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))
2025-12-18 22:50:21 -08:00
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
2025-12-17 17:42:46 -08:00
local function write_temp_log(prefix, text)
if not text or text == '' then
return nil
end
2025-12-18 22:50:21 -08:00
local dir = ''
-- Prefer repo-root Log/ for easier discovery.
2025-12-24 02:13:21 -08:00
-- 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.
2025-12-18 22:50:21 -08:00
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
2025-12-17 17:42:46 -08:00
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
2025-12-24 02:13:21 -08:00
-- 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
2025-12-17 17:42:46 -08:00
end
fh:write(text)
fh:close()
return path
end
local function trim(s)
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
end
2025-12-12 21:55:38 -08:00
-- 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)
2025-11-27 10:59:01 -08:00
-- Configuration
local opts = {
python_path = "python",
cli_path = nil -- Will be auto-detected if nil
}
2025-12-17 17:42:46 -08:00
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
2025-12-18 22:50:21 -08:00
local _cached_store_names = {}
local _store_cache_loaded = false
2025-12-27 03:13:16 -08:00
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
2025-12-17 17:42:46 -08:00
local _pipeline_helper_started = false
2025-12-19 02:29:42 -08:00
local _last_ipc_error = ''
local _last_ipc_last_req_json = ''
local _last_ipc_last_resp_json = ''
2025-12-17 17:42:46 -08:00
2025-12-18 22:50:21 -08:00
local function _is_pipeline_helper_ready()
2025-12-27 03:13:16 -08:00
local ready = mp.get_property(PIPELINE_READY_PROP)
if ready == nil or ready == '' then
ready = mp.get_property_native(PIPELINE_READY_PROP)
end
2025-12-18 22:50:21 -08:00
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
2025-12-27 03:13:16 -08:00
-- If it's some other non-empty value, treat as ready.
2025-12-18 22:50:21 -08:00
return true
end
2025-12-17 17:42:46 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-17 17:42:46 -08:00
return true
end
2025-12-18 22:50:21 -08:00
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
2025-12-24 02:13:21 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-23 16:36:39 -08:00
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
2025-12-24 02:13:21 -08:00
if video_info.image == true then
2025-12-23 16:36:39 -08:00
return true
end
2025-12-24 02:13:21 -08:00
if video_info.image == false then
2025-12-23 16:36:39 -08:00
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
2025-12-27 06:05:07 -08:00
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
2025-12-23 16:36:39 -08:00
local function _capture_screenshot()
2025-12-27 06:05:07 -08:00
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
2025-12-23 16:36:39 -08:00
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()
2025-12-18 22:50:21 -08:00
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({
2025-12-24 02:13:21 -08:00
-- Hide the PowerShell console window (dialog still shows).
args = { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps },
2025-12-18 22:50:21 -08:00
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)
2025-12-19 02:29:42 -08:00
_last_ipc_error = ''
2025-12-18 22:50:21 -08:00
if not ensure_pipeline_helper_running() then
2025-12-27 03:13:16 -08:00
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')
2025-12-24 02:13:21 -08:00
_last_ipc_error = 'helper not ready'
2025-12-18 22:50:21 -08:00
end
do
2025-12-27 03:13:16 -08:00
-- Best-effort wait for heartbeat, but do not hard-fail the request.
local deadline = mp.get_time() + 1.5
2025-12-18 22:50:21 -08:00
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
2025-12-27 03:13:16 -08:00
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 .. ')'
2025-12-18 22:50:21 -08:00
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)
2025-12-19 02:29:42 -08:00
local req_json = utils.format_json(req)
_last_ipc_last_req_json = req_json
2025-12-18 22:50:21 -08:00
mp.set_property(PIPELINE_RESP_PROP, '')
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-19 02:29:42 -08:00
_last_ipc_last_resp_json = resp_json
2025-12-18 22:50:21 -08:00
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)
2025-12-19 02:29:42 -08:00
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
2025-12-18 22:50:21 -08:00
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()
2025-12-27 03:13:16 -08:00
-- 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)')
2025-12-18 22:50:21 -08:00
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
2025-12-27 03:13:16 -08:00
_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 '')
)
2025-12-18 22:50:21 -08:00
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
2025-12-27 03:13:16 -08:00
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))
2025-12-18 22:50:21 -08:00
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
2025-12-27 03:13:16 -08:00
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)
2025-12-18 22:50:21 -08:00
-- 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
2025-12-19 02:29:42 -08:00
-- 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()
2025-12-18 22:50:21 -08:00
-- Cache yt-dlp format lists per URL so Change Format is instant.
local _formats_cache = {}
local _formats_inflight = {}
2025-12-19 02:29:42 -08:00
local _formats_waiters = {}
local _ipc_async_busy = false
local _ipc_async_queue = {}
2025-12-18 22:50:21 -08:00
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() }
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-19 02:29:42 -08:00
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 '')
2025-12-18 22:50:21 -08:00
if url == '' or not _is_http_url(url) then
2025-12-19 02:29:42 -08:00
if cb then cb(false, 'not a url') end
2025-12-18 22:50:21 -08:00
return
end
-- Only applies to plain URLs (not store hash URLs).
if _extract_store_hash(url) then
2025-12-19 02:29:42 -08:00
if cb then cb(false, 'store-hash url') end
2025-12-18 22:50:21 -08:00
return
end
2025-12-19 02:29:42 -08:00
-- 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
2025-12-18 22:50:21 -08:00
return
end
2025-12-19 02:29:42 -08:00
-- In-flight: register waiter.
2025-12-18 22:50:21 -08:00
if _formats_inflight[url] then
2025-12-19 02:29:42 -08:00
_formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end
2025-12-18 22:50:21 -08:00
return
end
_formats_inflight[url] = true
2025-12-19 02:29:42 -08:00
_formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end
2025-12-18 22:50:21 -08:00
2025-12-19 02:29:42 -08:00
-- Async request so the UI never blocks.
_run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err)
2025-12-18 22:50:21 -08:00
_formats_inflight[url] = nil
2025-12-19 02:29:42 -08:00
local ok = false
local reason = err
2025-12-18 22:50:21 -08:00
if resp and resp.success and type(resp.table) == 'table' then
2025-12-19 02:29:42 -08:00
ok = true
reason = nil
self:set_formats(url, resp.table)
2025-12-18 22:50:21 -08:00
_cache_formats_for_url(url, resp.table)
_lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
2025-12-19 02:29:42 -08:00
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)
2025-12-18 22:50:21 -08:00
end
end)
end
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-24 02:13:21 -08:00
local resp = _run_helper_request_response({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0)
return (resp and resp.success) and true or false
2025-12-18 22:50:21 -08:00
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()
2025-12-27 03:13:16 -08:00
pcall(_ensure_selected_store_loaded)
2025-12-18 22:50:21 -08:00
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()
2025-12-20 02:12:45 -08:00
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))
2025-12-18 22:50:21 -08:00
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)
2025-12-19 02:29:42 -08:00
-- Ensure file state is tracking the current URL.
if type(M.file) == 'table' then
M.file.url = url
end
2025-12-18 22:50:21 -08:00
-- If formats were already prefetched for this URL, open instantly.
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
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
2025-12-19 02:29:42 -08:00
_debug_dump_formatted_formats(url, cached_tbl, items)
2025-12-18 22:50:21 -08:00
_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')
2025-12-19 02:29:42 -08:00
-- 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'
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
_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
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
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
2025-12-18 22:50:21 -08:00
2025-12-19 02:29:42 -08:00
_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
2025-12-18 22:50:21 -08:00
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-media -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-media -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()
2025-12-24 02:13:21 -08:00
-- 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
2025-12-17 17:42:46 -08:00
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
2025-12-18 22:50:21 -08:00
if _is_pipeline_helper_ready() then
2025-12-17 17:42:46 -08:00
break
end
mp.wait_event(0.05)
end
2025-12-18 22:50:21 -08:00
if not _is_pipeline_helper_ready() then
2025-12-17 17:42:46 -08:00
_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
2025-11-27 10:59:01 -08:00
-- Detect CLI path
2025-12-07 00:21:30 -08:00
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 ""
2025-11-27 10:59:01 -08:00
if not opts.cli_path then
2025-12-17 17:42:46 -08:00
-- 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"
2025-11-27 10:59:01 -08:00
end
2025-12-27 03:13:16 -08:00
-- 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'
2025-12-17 17:42:46 -08:00
end
2025-12-27 03:13:16 -08:00
_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 }
2025-12-17 17:42:46 -08:00
end
2025-11-27 10:59:01 -08:00
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
2025-12-18 22:50:21 -08:00
_lua_log("Failed to parse JSON: " .. output)
2025-11-27 10:59:01 -08:00
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
2025-12-18 22:50:21 -08:00
_lua_log("Metadata: " .. utils.format_json(data))
2025-11-27 10:59:01 -08:00
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
2025-12-17 17:42:46 -08:00
-- 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)
2025-12-18 22:50:21 -08:00
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
2025-12-17 17:42:46 -08:00
end
2025-12-23 16:36:39 -08:00
-- 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)
2025-12-27 03:13:16 -08:00
_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
2025-12-23 16:36:39 -08:00
range = trim(tostring(range or ''))
2025-12-27 03:13:16 -08:00
_lua_log('trim: after_trim range=' .. tostring(range))
2025-12-23 16:36:39 -08:00
if range == '' then
mp.osd_message('Trim cancelled (no range provided)', 3)
2025-12-27 03:13:16 -08:00
_lua_log('trim: CANCELLED - empty range')
2025-12-23 16:36:39 -08:00
return
end
local target = _current_target()
if not target or target == '' then
mp.osd_message('No file to trim', 3)
2025-12-27 03:13:16 -08:00
_lua_log('trim: FAILED - no target')
2025-12-23 16:36:39 -08:00
return
end
2025-12-27 03:13:16 -08:00
_lua_log('trim: target=' .. tostring(target))
2025-12-23 16:36:39 -08:00
local store_hash = _extract_store_hash(target)
2025-12-27 03:13:16 -08:00
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'))
2025-12-23 16:36:39 -08:00
local stream = trim(tostring(mp.get_property('stream-open-filename') or ''))
if stream == '' then
stream = tostring(target)
end
2025-12-27 03:13:16 -08:00
_lua_log('trim: stream=' .. tostring(stream))
2025-12-23 16:36:39 -08:00
2025-12-27 03:13:16 -08:00
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'))
2025-12-23 16:36:39 -08:00
if store_hash then
2025-12-27 03:13:16 -08:00
-- 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
2025-12-23 16:36:39 -08:00
else
2025-12-27 03:13:16 -08:00
-- 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)
2025-12-23 16:36:39 -08:00
else
2025-12-27 03:13:16 -08:00
mp.osd_message('Trim complete: ' .. output_path, 5)
_lua_log('trim: no store selected, trim complete at ' .. output_path)
return
2025-12-23 16:36:39 -08:00
end
end
2025-12-27 03:13:16 -08:00
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)
2025-12-23 16:36:39 -08:00
end
end
function M.open_trim_prompt()
2025-12-27 03:13:16 -08:00
_lua_log('=== OPEN_TRIM_PROMPT called')
2025-12-23 16:36:39 -08:00
local marker_range = _get_trim_range_from_clip_markers()
2025-12-27 03:13:16 -08:00
_lua_log('trim_prompt: marker_range=' .. tostring(marker_range or 'NONE'))
2025-12-23 16:36:39 -08:00
if marker_range then
2025-12-27 03:13:16 -08:00
_lua_log('trim_prompt: using auto-detected markers, starting trim')
mp.osd_message('Using clip markers: ' .. marker_range, 2)
2025-12-23 16:36:39 -08:00
_start_trim_with_range(marker_range)
return
end
2025-12-27 03:13:16 -08:00
_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)'
2025-12-23 16:36:39 -08:00
local menu_data = {
type = TRIM_PROMPT_MENU_TYPE,
title = 'Trim file',
search_style = 'palette',
search_debounce = 'submit',
on_search = 'callback',
2025-12-27 03:13:16 -08:00
footnote = "Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter" .. store_hint,
2025-12-23 16:36:39 -08:00
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)
2025-12-17 17:42:46 -08:00
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
2025-12-18 22:50:21 -08:00
ensure_mpv_ipc_server()
local out = M.run_pipeline('.pipe -url ' .. quote_pipeline_arg(url) .. ' -play')
2025-12-17 17:42:46 -08:00
if out ~= nil then
2025-12-18 22:50:21 -08:00
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
2025-12-17 17:42:46 -08:00
end
end)
2025-11-27 10:59:01 -08:00
-- 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" },
2025-12-17 17:42:46 -08:00
{ title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} },
2025-12-23 16:36:39 -08:00
{ title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" },
2025-12-18 22:50:21 -08:00
{ 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"} },
2025-11-27 10:59:01 -08:00
}
}
local json = utils.format_json(menu_data)
2025-12-18 22:50:21 -08:00
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
2025-11-27 10:59:01 -08:00
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)
2025-12-12 21:55:38 -08:00
-- 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)
2025-12-24 02:13:21 -08:00
2025-12-23 16:36:39 -08:00
2025-12-17 17:42:46 -08:00
-- 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()
2025-12-18 22:50:21 -08:00
pcall(ensure_mpv_ipc_server)
pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION)
2025-12-27 06:05:07 -08:00
-- 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)
2025-12-17 17:42:46 -08:00
end)
2025-11-27 10:59:01 -08:00
return M