local mp = require 'mp' local utils = require 'mp.utils' local msg = require 'mp.msg' local M = {} local MEDEIA_LUA_VERSION = '2025-12-18' -- Track whether uosc is available so menu calls don't fail with -- "Can't find script 'uosc' to send message to." local _uosc_loaded = false mp.register_script_message('uosc-version', function(_ver) _uosc_loaded = true end) local function _is_script_loaded(name) local ok, list = pcall(mp.get_property_native, 'script-list') if not ok or type(list) ~= 'table' then return false end for _, s in ipairs(list) do if type(s) == 'table' then local n = s.name or '' if n == name or tostring(n):match('^' .. name .. '%d*$') then return true end elseif type(s) == 'string' then local n = s if n == name or tostring(n):match('^' .. name .. '%d*$') then return true end end end return false end local LOAD_URL_MENU_TYPE = 'medios_load_url' local DOWNLOAD_FORMAT_MENU_TYPE = 'medios_download_pick_format' local DOWNLOAD_STORE_MENU_TYPE = 'medios_download_pick_store' local 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. 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') -- Best-effort create dir. local sep = package and package.config and package.config:sub(1, 1) or '/' if sep == '\\' then pcall(utils.subprocess, { args = { 'cmd.exe', '/c', 'mkdir "' .. dir .. '" 1>nul 2>nul' } }) else pcall(utils.subprocess, { args = { 'sh', '-lc', 'mkdir -p ' .. string.format('%q', dir) .. ' >/dev/null 2>&1' } }) end 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 return nil 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 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 _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 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({ args = { 'powershell', '-NoProfile', '-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) if not ensure_pipeline_helper_running() then _lua_log('ipc: helper not running; cannot execute request') 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 _lua_log('ipc: helper not ready; ready=' .. tostring(mp.get_property_native(PIPELINE_READY_PROP))) _pipeline_helper_started = false 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) 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 _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) _pipeline_helper_started = false 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 '')) -- Fallback: directly call Python to import CLI.get_store_choices(). -- This avoids helper IPC issues and still stays in sync with the REPL. local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local cli_path = (opts and opts.cli_path) and tostring(opts.cli_path) or nil if not cli_path or cli_path == '' or not utils.file_info(cli_path) then local base_dir = mp.get_script_directory() or utils.getcwd() or '' if base_dir ~= '' then cli_path = find_file_upwards(base_dir, 'CLI.py', 8) end end if cli_path and cli_path ~= '' then local root = tostring(cli_path):match('(.*)[/\\]') or '' if root ~= '' then local code = "import json, sys; sys.path.insert(0, r'" .. root .. "'); from CLI import get_store_choices; print(json.dumps(get_store_choices()))" local res = utils.subprocess({ args = { python, '-c', code }, cancellable = false, }) if res and res.status == 0 and res.stdout then local out_text = tostring(res.stdout) local last_line = '' for line in out_text:gmatch('[^\r\n]+') do if trim(line) ~= '' then last_line = line end end local ok, parsed = pcall(utils.parse_json, last_line ~= '' and last_line or out_text) if ok and type(parsed) == 'table' then local out = {} for _, v in ipairs(parsed) do local name = trim(tostring(v or '')) if name ~= '' then out[#out + 1] = name end end if #out > 0 then _cached_store_names = out _store_cache_loaded = true _lua_log('stores: loaded ' .. tostring(#_cached_store_names) .. ' stores via python fallback') return true end end else _lua_log('stores: python fallback failed; status=' .. tostring(res and res.status or 'nil') .. ' stderr=' .. tostring(res and res.stderr or '')) end end end 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 -- Cache yt-dlp format lists per URL so Change Format is instant. M.file = M.file or {} M.file.formats_table = nil M.file.url = nil local _formats_cache = {} local _formats_inflight = {} 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() } M.file.url = url M.file.formats_table = tbl 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 _prefetch_formats_for_url(url) url = tostring(url or '') if url == '' or not _is_http_url(url) then return end -- Only applies to plain URLs (not store hash URLs). if _extract_store_hash(url) then return end if _get_cached_formats_table(url) then return end if _formats_inflight[url] then return end _formats_inflight[url] = true mp.add_timeout(0.01, function() if _get_cached_formats_table(url) then _formats_inflight[url] = nil return end ensure_mpv_ipc_server() local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 20) _formats_inflight[url] = nil if resp and resp.success and type(resp.table) == 'table' then _cache_formats_for_url(url, resp.table) _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url') 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 _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 python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local cli = (opts and opts.cli_path) and tostring(opts.cli_path) or 'CLI.py' local args = { python, cli, 'pipeline', '--pipeline', pipeline_cmd } local ok = utils.subprocess_detached({ args = args }) return ok ~= nil 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) .. ' -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) -- If formats were already prefetched for this URL, open instantly. local cached_tbl = _get_cached_formats_table(url) 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 _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') mp.add_timeout(0.05, function() if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then return end ensure_mpv_ipc_server() _lua_log('change-format: requesting formats via helper op for url') local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 30) if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then return end if not resp or not resp.success or type(resp.table) ~= 'table' then local err = '' if type(resp) == 'table' then if resp.error and tostring(resp.error) ~= '' then err = tostring(resp.error) end if resp.stderr and tostring(resp.stderr) ~= '' then err = (err ~= '' and (err .. ' | ') or '') .. tostring(resp.stderr) end end _lua_log('change-format: formats failed: ' .. (err ~= '' and err or '(no details)')) mp.osd_message('Failed to load format list', 5) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', { { title = 'Failed to load format list', hint = 'Check logs (medeia-mpv-lua.log / medeia-mpv-helper.log)', value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' }, }, }) return end local tbl = resp.table if 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 _cache_formats_for_url(url, tbl) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items) 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() -- If a helper is already running (e.g. started by the launcher), just use it. if _is_pipeline_helper_ready() then _pipeline_helper_started = true return true end -- We tried to start a helper before but it isn't ready anymore; restart. if _pipeline_helper_started then _pipeline_helper_started = false end local helper_path = nil -- Prefer deriving repo root from located CLI.py if available. if opts and opts.cli_path and utils.file_info(opts.cli_path) then local root = tostring(opts.cli_path):match('(.*)[/\\]') or '' if root ~= '' then local candidate = utils.join_path(root, 'MPV/pipeline_helper.py') if utils.file_info(candidate) then helper_path = candidate end end end if not helper_path then local base_dir = mp.get_script_directory() or "" if base_dir == "" then base_dir = utils.getcwd() or "" end helper_path = find_file_upwards(base_dir, 'MPV/pipeline_helper.py', 8) end if not helper_path then _lua_log('ipc: cannot find helper script MPV/pipeline_helper.py (script_dir=' .. tostring(mp.get_script_directory() or '') .. ')') return false end -- Ensure mpv actually has a JSON IPC server for the helper to connect to. if not ensure_mpv_ipc_server() then _lua_log('ipc: mpv input-ipc-server is not set; start mpv with --input-ipc-server=\\\\.\\pipe\\mpv-medeia-macina') return false end local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local ipc = get_mpv_ipc_path() -- Give the helper enough time to connect (Windows pipe can take a moment). local args = {python, helper_path, '--ipc', ipc, '--timeout', '30'} _lua_log('ipc: starting helper: ' .. table.concat(args, ' ')) local ok = utils.subprocess_detached({ args = args }) if ok == nil then _lua_log('ipc: failed to start helper (subprocess_detached returned nil)') return false end _pipeline_helper_started = true return true 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 local args = {opts.python_path, opts.cli_path, "pipeline", "--pipeline", pipeline_cmd} if seeds then local seeds_json = utils.format_json(seeds) table.insert(args, "--seeds-json") table.insert(args, seeds_json) end _lua_log("Running pipeline: " .. pipeline_cmd) -- If the persistent IPC helper isn't available, fall back to a subprocess. -- Note: mpv's subprocess helper does not support an `env` parameter. local res = utils.subprocess({ args = args, cancellable = false, }) if res.status ~= 0 then local err = (res.stderr and res.stderr ~= "") and res.stderr or (res.error_string and res.error_string ~= "") and res.error_string or "unknown" local log_path = write_temp_log('medeia-cli-pipeline-stderr', tostring(res.stderr or err)) local suffix = log_path and (' (log: ' .. log_path .. ')') or '' _lua_log("Pipeline error: " .. err .. suffix) mp.osd_message("Error: pipeline failed" .. suffix, 6) return nil end return res.stdout 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 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 = "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(ensure_pipeline_helper_running) pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION) end) return M