local mp = require 'mp' local utils = require 'mp.utils' local msg = require 'mp.msg' local M = {} local MEDEIA_LUA_VERSION = '2025-12-24' -- 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 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 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 _pipeline_helper_started = false local _last_ipc_error = '' local _last_ipc_last_req_json = '' local _last_ipc_last_resp_json = '' local function _is_pipeline_helper_ready() local ready = mp.get_property_native(PIPELINE_READY_PROP) 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 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 _capture_screenshot() mp.commandv('screenshot') mp.osd_message('Screenshot captured', 0.7) 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 _lua_log('ipc: helper not ready; cannot execute request') _last_ipc_error = 'helper not ready' return nil end do local deadline = mp.get_time() + 3.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 local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP)) _lua_log('ipc: helper not ready; ready=' .. rv) _last_ipc_error = 'helper not ready (ready=' .. rv .. ')' return nil 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() local resp = _run_helper_request_response({ op = 'store-choices' }, timeout_seconds or 1) if not resp or not resp.success or type(resp.choices) ~= 'table' then _lua_log('stores: failed to load store choices via helper; stderr=' .. tostring(resp and resp.stderr or '') .. ' error=' .. tostring(resp and resp.error or '')) 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 _lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via helper') 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 -- 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() 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-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() -- 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: /MPV/LUA/main.lua, and /CLI.py opts.cli_path = find_file_upwards(script_dir, "CLI.py", 6) or "CLI.py" end -- Helper to run pipeline function M.run_pipeline(pipeline_cmd, seeds) local out, err = run_pipeline_via_ipc(pipeline_cmd, seeds, 5) if out ~= nil then return out end if err ~= nil then local log_path = write_temp_log('medeia-pipeline-error', tostring(err)) local suffix = log_path and (' (log: ' .. log_path .. ')') or '' _lua_log('Pipeline error: ' .. tostring(err) .. suffix) mp.osd_message('Error: pipeline failed' .. suffix, 6) return nil end mp.osd_message('Error: pipeline helper not available', 6) _lua_log('ipc: helper not available; refusing to spawn python subprocess') return nil end -- Helper to run pipeline and parse JSON output 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) range = trim(tostring(range or '')) if range == '' then mp.osd_message('Trim cancelled (no range provided)', 3) return end local target = _current_target() if not target or target == '' then mp.osd_message('No file to trim', 3) return end local store_hash = _extract_store_hash(target) -- Prefer the resolved stream URL/filename so trimming can avoid full downloads where possible. local stream = trim(tostring(mp.get_property('stream-open-filename') or '')) if stream == '' then stream = tostring(target) end local pipeline_cmd if store_hash then pipeline_cmd = 'get-tag -emit -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' | trim-file -input ' .. quote_pipeline_arg(stream) .. ' -range ' .. quote_pipeline_arg(range) .. ' | add-file -store ' .. quote_pipeline_arg(store_hash.store) else if utils.file_info(tostring(target)) then pipeline_cmd = 'trim-file -path ' .. quote_pipeline_arg(target) .. ' -range ' .. quote_pipeline_arg(range) else pipeline_cmd = 'trim-file -input ' .. quote_pipeline_arg(stream) .. ' -range ' .. quote_pipeline_arg(range) end end if not _run_pipeline_detached(pipeline_cmd) then M.run_pipeline(pipeline_cmd) end mp.osd_message('Trim started', 3) end function M.open_trim_prompt() local marker_range = _get_trim_range_from_clip_markers() if marker_range then _start_trim_with_range(marker_range) return end 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", 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) end) return M