2025-11-27 10:59:01 -08:00
local mp = require ' mp '
local utils = require ' mp.utils '
local msg = require ' mp.msg '
local M = { }
2025-12-24 02:13:21 -08:00
local MEDEIA_LUA_VERSION = ' 2025-12-24 '
2025-12-18 22:50:21 -08:00
2025-12-27 03:13:16 -08:00
-- Expose a tiny breadcrumb for debugging which script version is loaded.
pcall ( mp.set_property , ' user-data/medeia-lua-version ' , MEDEIA_LUA_VERSION )
2025-12-18 22:50:21 -08:00
-- Track whether uosc is available so menu calls don't fail with
-- "Can't find script 'uosc' to send message to."
local _uosc_loaded = false
mp.register_script_message ( ' uosc-version ' , function ( _ver )
_uosc_loaded = true
end )
local function _is_script_loaded ( name )
local ok , list = pcall ( mp.get_property_native , ' script-list ' )
if not ok or type ( list ) ~= ' table ' then
return false
end
for _ , s in ipairs ( list ) do
if type ( s ) == ' table ' then
local n = s.name or ' '
if n == name or tostring ( n ) : match ( ' ^ ' .. name .. ' %d*$ ' ) then
return true
end
elseif type ( s ) == ' string ' then
local n = s
if n == name or tostring ( n ) : match ( ' ^ ' .. name .. ' %d*$ ' ) then
return true
end
end
end
return false
end
2025-12-17 17:42:46 -08:00
local LOAD_URL_MENU_TYPE = ' medios_load_url '
2025-12-18 22:50:21 -08:00
local DOWNLOAD_FORMAT_MENU_TYPE = ' medios_download_pick_format '
local DOWNLOAD_STORE_MENU_TYPE = ' medios_download_pick_store '
2025-12-23 16:36:39 -08:00
-- Menu types for the command submenu and trim prompt
local CMD_MENU_TYPE = ' medios_cmd_menu '
local TRIM_PROMPT_MENU_TYPE = ' medios_trim_prompt '
2025-12-17 17:42:46 -08:00
local PIPELINE_REQ_PROP = ' user-data/medeia-pipeline-request '
local PIPELINE_RESP_PROP = ' user-data/medeia-pipeline-response '
local PIPELINE_READY_PROP = ' user-data/medeia-pipeline-ready '
2025-12-18 22:50:21 -08:00
-- Dedicated Lua log (next to mpv log-file) because mp.msg output is not always
-- included in --log-file depending on msg-level and build.
local function _lua_log ( text )
local payload = ( text and tostring ( text ) or ' ' )
if payload == ' ' then
return
end
local dir = ' '
2025-12-27 03:13:16 -08:00
-- Prefer a stable repo-root Log/ folder based on the script directory.
do
local function _dirname ( p )
p = tostring ( p or ' ' )
p = p : gsub ( ' [/ \\ ]+$ ' , ' ' )
return p : match ( ' (.*)[/ \\ ] ' ) or ' '
end
local base = mp.get_script_directory ( ) or ' '
if base ~= ' ' then
-- base is expected to be <repo>/MPV/LUA
local root = _dirname ( _dirname ( base ) )
if root ~= ' ' then
dir = utils.join_path ( root , ' Log ' )
end
end
end
2025-12-18 22:50:21 -08:00
-- Prefer repo-root Log/ for consistency with Python helper logs.
do
local function find_up ( start_dir , relative_path , max_levels )
local d = start_dir
local levels = max_levels or 6
for _ = 0 , levels do
if d and d ~= ' ' then
local candidate = d .. ' / ' .. relative_path
if utils.file_info ( candidate ) then
return candidate
end
end
local parent = d and d : match ( ' (.*)[/ \\ ] ' ) or nil
if not parent or parent == d or parent == ' ' then
break
end
d = parent
end
return nil
end
local base = mp.get_script_directory ( ) or utils.getcwd ( ) or ' '
if base ~= ' ' then
local cli = find_up ( base , ' CLI.py ' , 8 )
if cli and cli ~= ' ' then
local root = cli : match ( ' (.*)[/ \\ ] ' ) or ' '
if root ~= ' ' then
dir = utils.join_path ( root , ' Log ' )
end
end
end
end
-- Fallback: next to mpv --log-file.
if dir == ' ' then
local log_file = mp.get_property ( ' options/log-file ' ) or ' '
dir = log_file : match ( ' (.*)[/ \\ ] ' ) or ' '
end
if dir == ' ' then
dir = mp.get_script_directory ( ) or utils.getcwd ( ) or ' '
end
if dir == ' ' then
return
end
local path = utils.join_path ( dir , ' medeia-mpv-lua.log ' )
local fh = io.open ( path , ' a ' )
if not fh then
return
end
local line = ' [ ' .. os.date ( ' %Y-%m-%d %H:%M:%S ' ) .. ' ] ' .. payload
fh : write ( line .. ' \n ' )
fh : close ( )
-- Also mirror Lua-side debug into the Python helper log file so there's one
-- place to look when diagnosing mpv↔python IPC issues.
do
local helper_path = utils.join_path ( dir , ' medeia-mpv-helper.log ' )
local fh2 = io.open ( helper_path , ' a ' )
if fh2 then
fh2 : write ( ' [lua] ' .. line .. ' \n ' )
fh2 : close ( )
end
end
end
2025-12-27 03:13:16 -08:00
_lua_log ( ' medeia lua loaded version= ' .. tostring ( MEDEIA_LUA_VERSION ) .. ' script= ' .. tostring ( mp.get_script_name ( ) ) )
2025-12-18 22:50:21 -08:00
local function ensure_uosc_loaded ( )
if _uosc_loaded or _is_script_loaded ( ' uosc ' ) then
_uosc_loaded = true
return true
end
local entry = nil
pcall ( function ( )
entry = mp.find_config_file ( ' scripts/uosc.lua ' )
end )
if not entry or entry == ' ' then
_lua_log ( ' uosc entry not found at scripts/uosc.lua under config-dir ' )
return false
end
local ok = pcall ( mp.commandv , ' load-script ' , entry )
if ok then
_lua_log ( ' Loaded uosc from: ' .. tostring ( entry ) )
else
_lua_log ( ' Failed to load uosc from: ' .. tostring ( entry ) )
end
-- uosc will broadcast uosc-version on load; also re-check script-list if available.
if _is_script_loaded ( ' uosc ' ) then
_uosc_loaded = true
return true
end
return _uosc_loaded
end
2025-12-17 17:42:46 -08:00
local function write_temp_log ( prefix , text )
if not text or text == ' ' then
return nil
end
2025-12-18 22:50:21 -08:00
local dir = ' '
-- Prefer repo-root Log/ for easier discovery.
2025-12-24 02:13:21 -08:00
-- NOTE: Avoid spawning cmd.exe/sh just to mkdir on Windows/Linux; console flashes are
-- highly undesirable. If the directory doesn't exist, we fall back to TEMP.
2025-12-18 22:50:21 -08:00
do
local function find_up ( start_dir , relative_path , max_levels )
local d = start_dir
local levels = max_levels or 6
for _ = 0 , levels do
if d and d ~= ' ' then
local candidate = d .. ' / ' .. relative_path
if utils.file_info ( candidate ) then
return candidate
end
end
local parent = d and d : match ( ' (.*)[/ \\ ] ' ) or nil
if not parent or parent == d or parent == ' ' then
break
end
d = parent
end
return nil
end
local base = mp.get_script_directory ( ) or utils.getcwd ( ) or ' '
if base ~= ' ' then
local cli = find_up ( base , ' CLI.py ' , 6 )
if cli and cli ~= ' ' then
local parent = cli : match ( ' (.*)[/ \\ ] ' ) or ' '
if parent ~= ' ' then
dir = utils.join_path ( parent , ' Log ' )
end
end
end
end
if dir == ' ' then
dir = os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or utils.getcwd ( ) or ' '
end
2025-12-17 17:42:46 -08:00
if dir == ' ' then
return nil
end
local name = ( prefix or ' medeia-mpv ' ) .. ' - ' .. tostring ( math.floor ( mp.get_time ( ) * 1000 ) ) .. ' .log '
local path = utils.join_path ( dir , name )
local fh = io.open ( path , ' w ' )
if not fh then
2025-12-24 02:13:21 -08:00
-- If Log/ wasn't created (or is not writable), fall back to TEMP.
local tmp = os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or ' '
if tmp ~= ' ' and tmp ~= dir then
path = utils.join_path ( tmp , name )
fh = io.open ( path , ' w ' )
end
if not fh then
return nil
end
2025-12-17 17:42:46 -08:00
end
fh : write ( text )
fh : close ( )
return path
end
local function trim ( s )
return ( s : gsub ( ' ^%s+ ' , ' ' ) : gsub ( ' %s+$ ' , ' ' ) )
end
2025-12-12 21:55:38 -08:00
-- Lyrics overlay toggle
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
local LYRIC_VISIBLE_PROP = " user-data/medeia-lyric-visible "
local function lyric_get_visible ( )
local ok , v = pcall ( mp.get_property_native , LYRIC_VISIBLE_PROP )
if not ok or v == nil then
return true
end
return v and true or false
end
local function lyric_set_visible ( v )
pcall ( mp.set_property_native , LYRIC_VISIBLE_PROP , v and true or false )
end
local function lyric_toggle ( )
local now = not lyric_get_visible ( )
lyric_set_visible ( now )
mp.osd_message ( " Lyrics: " .. ( now and " on " or " off " ) , 1 )
end
-- Default to visible unless user overrides.
lyric_set_visible ( true )
2025-11-27 10:59:01 -08:00
-- Configuration
local opts = {
python_path = " python " ,
cli_path = nil -- Will be auto-detected if nil
}
2025-12-17 17:42:46 -08:00
local function find_file_upwards ( start_dir , relative_path , max_levels )
local dir = start_dir
local levels = max_levels or 6
for _ = 0 , levels do
if dir and dir ~= " " then
local candidate = dir .. " / " .. relative_path
if utils.file_info ( candidate ) then
return candidate
end
end
local parent = dir and dir : match ( " (.*)[/ \\ ] " ) or nil
if not parent or parent == dir or parent == " " then
break
end
dir = parent
end
return nil
end
2025-12-18 22:50:21 -08:00
local _cached_store_names = { }
local _store_cache_loaded = false
2025-12-27 03:13:16 -08:00
local SELECTED_STORE_PROP = ' user-data/medeia-selected-store '
local STORE_PICKER_MENU_TYPE = ' medeia_store_picker '
local _selected_store_loaded = false
local function _get_script_opts_dir ( )
local dir = nil
pcall ( function ( )
dir = mp.command_native ( { ' expand-path ' , ' ~~/script-opts ' } )
end )
if type ( dir ) ~= ' string ' or dir == ' ' then
return nil
end
return dir
end
local function _get_selected_store_conf_path ( )
local dir = _get_script_opts_dir ( )
if not dir then
return nil
end
return utils.join_path ( dir , ' medeia.conf ' )
end
local function _load_selected_store_from_disk ( )
local path = _get_selected_store_conf_path ( )
if not path then
return nil
end
local fh = io.open ( path , ' r ' )
if not fh then
return nil
end
for line in fh : lines ( ) do
local s = trim ( tostring ( line or ' ' ) )
if s ~= ' ' and s : sub ( 1 , 1 ) ~= ' # ' and s : sub ( 1 , 1 ) ~= ' ; ' then
local k , v = s : match ( ' ^([%w_%-]+)%s*=%s*(.*)$ ' )
if k and v and k : lower ( ) == ' store ' then
fh : close ( )
v = trim ( tostring ( v or ' ' ) )
return v ~= ' ' and v or nil
end
end
end
fh : close ( )
return nil
end
local function _save_selected_store_to_disk ( store )
local path = _get_selected_store_conf_path ( )
if not path then
return false
end
local fh = io.open ( path , ' w ' )
if not fh then
return false
end
fh : write ( ' # Medeia MPV script options \n ' )
fh : write ( ' store= ' .. tostring ( store or ' ' ) .. ' \n ' )
fh : close ( )
return true
end
local function _get_selected_store ( )
local v = ' '
pcall ( function ( )
v = tostring ( mp.get_property ( SELECTED_STORE_PROP ) or ' ' )
end )
return trim ( tostring ( v or ' ' ) )
end
local function _set_selected_store ( store )
store = trim ( tostring ( store or ' ' ) )
pcall ( mp.set_property , SELECTED_STORE_PROP , store )
pcall ( _save_selected_store_to_disk , store )
end
local function _ensure_selected_store_loaded ( )
if _selected_store_loaded then
return
end
_selected_store_loaded = true
local disk = nil
pcall ( function ( )
disk = _load_selected_store_from_disk ( )
end )
disk = trim ( tostring ( disk or ' ' ) )
if disk ~= ' ' then
pcall ( mp.set_property , SELECTED_STORE_PROP , disk )
end
end
2025-12-17 17:42:46 -08:00
local _pipeline_helper_started = false
2025-12-19 02:29:42 -08:00
local _last_ipc_error = ' '
local _last_ipc_last_req_json = ' '
local _last_ipc_last_resp_json = ' '
2025-12-17 17:42:46 -08:00
2025-12-18 22:50:21 -08:00
local function _is_pipeline_helper_ready ( )
2025-12-27 03:13:16 -08:00
local ready = mp.get_property ( PIPELINE_READY_PROP )
if ready == nil or ready == ' ' then
ready = mp.get_property_native ( PIPELINE_READY_PROP )
end
2025-12-18 22:50:21 -08:00
if not ready then
return false
end
local s = tostring ( ready )
if s == ' ' or s == ' 0 ' then
return false
end
-- Back-compat: older helpers may set "1". New helpers set unix timestamps.
local n = tonumber ( s )
if n and n > 1000000000 then
local now = ( os and os.time ) and os.time ( ) or nil
if not now then
return true
end
local age = now - n
if age < 0 then
age = 0
end
return age <= 10
end
2025-12-27 03:13:16 -08:00
-- If it's some other non-empty value, treat as ready.
2025-12-18 22:50:21 -08:00
return true
end
2025-12-17 17:42:46 -08:00
local function get_mpv_ipc_path ( )
local ipc = mp.get_property ( ' input-ipc-server ' )
if ipc and ipc ~= ' ' then
return ipc
end
-- Fallback: fixed pipe/socket name used by MPV/mpv_ipc.py
local sep = package and package.config and package.config : sub ( 1 , 1 ) or ' / '
if sep == ' \\ ' then
return ' \\ \\ . \\ pipe \\ mpv-medeia-macina '
end
return ' /tmp/mpv-medeia-macina.sock '
end
2025-12-18 22:50:21 -08:00
local function ensure_mpv_ipc_server ( )
-- `.pipe -play` (Python) controls MPV via JSON IPC. If mpv was started
-- without --input-ipc-server, make sure we set one so the running instance
-- can be controlled (instead of Python spawning a separate mpv).
local ipc = mp.get_property ( ' input-ipc-server ' )
if ipc and ipc ~= ' ' then
2025-12-17 17:42:46 -08:00
return true
end
2025-12-18 22:50:21 -08:00
local desired = get_mpv_ipc_path ( )
if not desired or desired == ' ' then
return false
end
local ok = pcall ( mp.set_property , ' input-ipc-server ' , desired )
if not ok then
return false
end
local now = mp.get_property ( ' input-ipc-server ' )
return ( now and now ~= ' ' ) and true or false
end
local function quote_pipeline_arg ( s )
-- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing.
s = tostring ( s or ' ' )
s = s : gsub ( ' \\ ' , ' \\ \\ ' ) : gsub ( ' " ' , ' \\ " ' )
return ' " ' .. s .. ' " '
end
local function _is_windows ( )
local sep = package and package.config and package.config : sub ( 1 , 1 ) or ' / '
return sep == ' \\ '
end
2025-12-24 02:13:21 -08:00
local function _resolve_python_exe ( prefer_no_console )
local python = ( opts and opts.python_path ) and tostring ( opts.python_path ) or ' python '
if ( not prefer_no_console ) or ( not _is_windows ( ) ) then
return python
end
local low = tostring ( python ) : lower ( )
if low == ' python ' then
return ' pythonw '
end
if low == ' python.exe ' then
return ' pythonw.exe '
end
if low : sub ( - 10 ) == ' python.exe ' then
local candidate = python : sub ( 1 , # python - 10 ) .. ' pythonw.exe '
if utils.file_info ( candidate ) then
return candidate
end
return ' pythonw '
end
-- Already pythonw or some other launcher.
return python
end
2025-12-18 22:50:21 -08:00
local function _extract_target_from_memory_uri ( text )
if type ( text ) ~= ' string ' then
return nil
end
if not text : match ( ' ^memory:// ' ) then
return nil
end
for line in text : gmatch ( ' [^ \r \n ]+ ' ) do
line = trim ( line )
if line ~= ' ' and not line : match ( ' ^# ' ) and not line : match ( ' ^memory:// ' ) then
return line
end
end
return nil
end
local function _percent_decode ( s )
if type ( s ) ~= ' string ' then
return s
end
return ( s : gsub ( ' %%(%x%x) ' , function ( hex )
return string.char ( tonumber ( hex , 16 ) )
end ) )
end
local function _extract_query_param ( url , key )
if type ( url ) ~= ' string ' then
return nil
end
key = tostring ( key or ' ' )
if key == ' ' then
return nil
end
local pattern = ' [?&] ' .. key : gsub ( ' ([^%w]) ' , ' %%%1 ' ) .. ' =([^&#]+) '
local v = url : match ( pattern )
if v then
return _percent_decode ( v )
end
return nil
end
local function _current_target ( )
local path = mp.get_property ( ' path ' )
if not path or path == ' ' then
return nil
end
local mem = _extract_target_from_memory_uri ( path )
if mem and mem ~= ' ' then
return mem
end
return path
end
2025-12-23 16:36:39 -08:00
local ImageControl = {
enabled = false ,
binding_names = { } ,
pan_step = 0.05 ,
pan_step_slow = 0.02 ,
zoom_step = 0.45 ,
zoom_step_slow = 0.15 ,
}
local MAX_IMAGE_ZOOM = 4.5
local function _install_q_block ( )
pcall ( mp.commandv , ' keybind ' , ' q ' , ' script-message ' , ' medeia-image-quit-block ' )
end
local function _restore_q_default ( )
pcall ( mp.commandv , ' keybind ' , ' q ' , ' quit ' )
end
local function _enable_image_section ( )
pcall ( mp.commandv , ' enable-section ' , ' image ' , ' allow-hide-cursor ' )
end
local function _disable_image_section ( )
pcall ( mp.commandv , ' disable-section ' , ' image ' )
end
mp.register_script_message ( ' medeia-image-quit-block ' , function ( )
if ImageControl.enabled then
mp.osd_message ( ' Press ESC if you really want to quit ' , 0.7 )
return
end
mp.commandv ( ' quit ' )
end )
local ImageExtensions = {
jpg = true ,
jpeg = true ,
png = true ,
gif = true ,
webp = true ,
bmp = true ,
tif = true ,
tiff = true ,
heic = true ,
heif = true ,
avif = true ,
ico = true ,
}
local function _clean_path_for_extension ( path )
if type ( path ) ~= ' string ' then
return nil
end
local clean = path : match ( ' ([^?]+) ' ) or path
clean = clean : match ( ' ([^#]+) ' ) or clean
local last = clean : match ( ' ([^/ \\ ]+)$ ' ) or ' '
local ext = last : match ( ' %.([A-Za-z0-9]+)$ ' )
if not ext then
return nil
end
return ext : lower ( )
end
local function _is_image_path ( path )
local ext = _clean_path_for_extension ( path )
return ext and ImageExtensions [ ext ]
end
local function _get_current_item_is_image ( )
local video_info = mp.get_property_native ( ' current-tracks/video ' )
if type ( video_info ) == ' table ' then
2025-12-24 02:13:21 -08:00
if video_info.image == true then
2025-12-23 16:36:39 -08:00
return true
end
2025-12-24 02:13:21 -08:00
if video_info.image == false then
2025-12-23 16:36:39 -08:00
return false
end
end
local target = _current_target ( )
if target then
return _is_image_path ( target )
end
return false
end
local function _set_image_property ( value )
pcall ( mp.set_property_native , ' user-data/mpv/image ' , value and true or false )
end
local function _show_image_status ( message )
local zoom = mp.get_property_number ( ' video-zoom ' ) or 0
local pan_x = mp.get_property_number ( ' video-pan-x ' ) or 0
local pan_y = mp.get_property_number ( ' video-pan-y ' ) or 0
local zoom_percent = math.floor ( ( 1 + zoom ) * 100 + 0.5 )
local text = string.format ( ' Image: zoom %d%% pan %+.2f %+.2f ' , zoom_percent , pan_x , pan_y )
if message and message ~= ' ' then
text = message .. ' | ' .. text
end
mp.osd_message ( text , 0.7 )
end
local function _change_pan ( dx , dy )
local pan_x = mp.get_property_number ( ' video-pan-x ' ) or 0
local pan_y = mp.get_property_number ( ' video-pan-y ' ) or 0
mp.set_property_number ( ' video-pan-x ' , pan_x + dx )
mp.set_property_number ( ' video-pan-y ' , pan_y + dy )
_show_image_status ( )
end
local function _change_zoom ( delta )
local current = mp.get_property_number ( ' video-zoom ' ) or 0
local target = current + delta
if target > MAX_IMAGE_ZOOM then
target = MAX_IMAGE_ZOOM
end
if target < - 1.0 then
target = - 1.0
end
mp.set_property_number ( ' video-zoom ' , target )
mp.set_property ( ' video-unscaled ' , ' no ' )
if target >= MAX_IMAGE_ZOOM then
mp.osd_message ( ' Image zoom maxed at 450% ' , 0.7 )
else
_show_image_status ( )
end
end
local function _reset_pan_zoom ( )
mp.set_property_number ( ' video-pan-x ' , 0 )
mp.set_property_number ( ' video-pan-y ' , 0 )
mp.set_property_number ( ' video-zoom ' , 0 )
mp.set_property ( ' video-align-x ' , ' 0 ' )
mp.set_property ( ' video-align-y ' , ' 0 ' )
mp.set_property ( ' panscan ' , 0 )
mp.set_property ( ' video-unscaled ' , ' no ' )
_show_image_status ( ' Zoom reset ' )
end
2025-12-27 06:05:07 -08:00
local function _sanitize_filename_component ( s )
s = trim ( tostring ( s or ' ' ) )
if s == ' ' then
return ' screenshot '
end
-- Windows-unfriendly characters: <>:"/\|?* and control chars
s = s : gsub ( ' [%c] ' , ' ' )
s = s : gsub ( ' [<>:"/ \\ |%?%*] ' , ' _ ' )
s = trim ( s )
s = s : gsub ( ' [%.%s]+$ ' , ' ' )
if s == ' ' then
return ' screenshot '
end
return s
end
local function _strip_title_extension ( title , path )
title = trim ( tostring ( title or ' ' ) )
if title == ' ' then
return title
end
path = tostring ( path or ' ' )
local ext = path : match ( ' %.([%w%d]+)$ ' )
if not ext or ext == ' ' then
return title
end
ext = ext : lower ( )
local suffix = ' . ' .. ext
if title : lower ( ) : sub ( -# suffix ) == suffix then
return trim ( title : sub ( 1 , # title - # suffix ) )
end
return title
end
2025-12-23 16:36:39 -08:00
local function _capture_screenshot ( )
2025-12-27 06:05:07 -08:00
local function _format_time_label ( seconds )
local total = math.max ( 0 , math.floor ( tonumber ( seconds or 0 ) or 0 ) )
local hours = math.floor ( total / 3600 )
local minutes = math.floor ( total / 60 ) % 60
local secs = total % 60
local parts = { }
if hours > 0 then
table.insert ( parts , ( ' %dh ' ) : format ( hours ) )
end
if minutes > 0 or hours > 0 then
table.insert ( parts , ( ' %dm ' ) : format ( minutes ) )
end
table.insert ( parts , ( ' %ds ' ) : format ( secs ) )
return table.concat ( parts )
end
local time = mp.get_property_number ( ' time-pos ' ) or mp.get_property_number ( ' time ' ) or 0
local label = _format_time_label ( time )
local raw_title = trim ( tostring ( mp.get_property ( ' media-title ' ) or ' ' ) )
local raw_path = tostring ( mp.get_property ( ' path ' ) or ' ' )
if raw_title == ' ' then
raw_title = ' screenshot '
end
raw_title = _strip_title_extension ( raw_title , raw_path )
local safe_title = _sanitize_filename_component ( raw_title )
local filename = safe_title .. ' _ ' .. label .. ' .png '
local temp_dir = mp.get_property ( ' user-data/medeia-config-temp ' ) or os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or ' /tmp '
local out_path = utils.join_path ( temp_dir , filename )
local ok = pcall ( function ( )
mp.commandv ( ' screenshot-to-file ' , out_path , ' video ' )
end )
if not ok then
mp.osd_message ( ' Screenshot failed ' , 2 )
return
end
_ensure_selected_store_loaded ( )
local selected_store = _get_selected_store ( )
selected_store = trim ( tostring ( selected_store or ' ' ) )
selected_store = selected_store : gsub ( ' ^ \" ' , ' ' ) : gsub ( ' \" $ ' , ' ' )
if selected_store == ' ' then
mp.osd_message ( ' Select a store first (Store button) ' , 2 )
return
end
local python_exe = _resolve_python_exe ( true )
if not python_exe or python_exe == ' ' then
mp.osd_message ( ' Screenshot saved; Python not found ' , 3 )
return
end
local start_dir = mp.get_script_directory ( ) or ' '
local cli_py = find_file_upwards ( start_dir , ' CLI.py ' , 8 )
if not cli_py or cli_py == ' ' or not utils.file_info ( cli_py ) then
mp.osd_message ( ' Screenshot saved; CLI.py not found ' , 3 )
return
end
local res = utils.subprocess ( {
args = { python_exe , cli_py , ' add-file ' , ' -store ' , selected_store , ' -path ' , out_path } ,
cancellable = false ,
} )
if res and res.status == 0 then
mp.osd_message ( ' Screenshot saved to store: ' .. selected_store , 3 )
else
local stderr = ( res and res.stderr ) or ' unknown error '
mp.osd_message ( ' Screenshot upload failed: ' .. tostring ( stderr ) , 5 )
end
2025-12-23 16:36:39 -08:00
end
mp.register_script_message ( ' medeia-image-screenshot ' , function ( )
_capture_screenshot ( )
end )
local CLIP_MARKER_SLOT_COUNT = 2
local clip_markers = { }
local initial_chapters = nil
local function _format_clip_marker_label ( time )
if type ( time ) ~= ' number ' then
return ' 0s '
end
local total = math.max ( 0 , math.floor ( time ) )
local hours = math.floor ( total / 3600 )
local minutes = math.floor ( total / 60 ) % 60
local seconds = total % 60
local parts = { }
if hours > 0 then
table.insert ( parts , ( ' %dh ' ) : format ( hours ) )
end
if minutes > 0 or hours > 0 then
table.insert ( parts , ( ' %dm ' ) : format ( minutes ) )
end
table.insert ( parts , ( ' %ds ' ) : format ( seconds ) )
return table.concat ( parts )
end
local function _apply_clip_chapters ( )
local chapters = { }
if initial_chapters then
for _ , chapter in ipairs ( initial_chapters ) do table.insert ( chapters , chapter ) end
end
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
local time = clip_markers [ idx ]
if time and type ( time ) == ' number ' then
table.insert ( chapters , {
time = time ,
title = _format_clip_marker_label ( time ) ,
} )
end
end
table.sort ( chapters , function ( a , b ) return ( a.time or 0 ) < ( b.time or 0 ) end )
mp.set_property_native ( ' chapter-list ' , chapters )
end
local function _reset_clip_markers ( )
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
clip_markers [ idx ] = nil
end
_apply_clip_chapters ( )
end
local function _capture_clip ( )
local time = mp.get_property_number ( ' time-pos ' ) or mp.get_property_number ( ' time ' )
if not time then
mp.osd_message ( ' Cannot capture clip; no time available ' , 0.7 )
return
end
local slot = nil
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
if not clip_markers [ idx ] then
slot = idx
break
end
end
if not slot then
local best = math.huge
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
local existing = clip_markers [ idx ]
local distance = math.abs ( ( existing or 0 ) - time )
if distance < best then
best = distance
slot = idx
end
end
slot = slot or 1
end
clip_markers [ slot ] = time
_apply_clip_chapters ( )
mp.commandv ( ' screenshot-to-file ' , ( ' clip-%s-%.0f.png ' ) : format ( os.date ( ' %Y%m%d-%H%M%S ' ) , time ) )
local label = _format_clip_marker_label ( time )
mp.osd_message ( ( ' Clip marker %d set at %s ' ) : format ( slot , label ) , 0.7 )
end
mp.register_event ( ' file-loaded ' , function ( )
initial_chapters = mp.get_property_native ( ' chapter-list ' ) or { }
_reset_clip_markers ( )
end )
mp.register_script_message ( ' medeia-image-clip ' , function ( )
_capture_clip ( )
end )
local function _get_trim_range_from_clip_markers ( )
local times = { }
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
local t = clip_markers [ idx ]
if type ( t ) == ' number ' then
table.insert ( times , t )
end
end
table.sort ( times , function ( a , b ) return a < b end )
if # times < 2 then
return nil
end
local start_t = times [ 1 ]
local end_t = times [ 2 ]
if type ( start_t ) ~= ' number ' or type ( end_t ) ~= ' number ' then
return nil
end
if end_t <= start_t then
return nil
end
return _format_clip_marker_label ( start_t ) .. ' - ' .. _format_clip_marker_label ( end_t )
end
local function _audio_only ( )
mp.commandv ( ' set ' , ' vid ' , ' no ' )
mp.osd_message ( ' Audio-only playback enabled ' , 1 )
end
mp.register_script_message ( ' medeia-audio-only ' , function ( )
_audio_only ( )
end )
local function _bind_image_key ( key , name , fn , opts )
opts = opts or { }
if ImageControl.binding_names [ name ] then
pcall ( mp.remove_key_binding , name )
ImageControl.binding_names [ name ] = nil
end
local ok , err = pcall ( mp.add_forced_key_binding , key , name , fn , opts )
if ok then
ImageControl.binding_names [ name ] = true
else
mp.msg . warn ( ' Failed to add image binding ' .. tostring ( key ) .. ' : ' .. tostring ( err ) )
end
end
local function _unbind_image_keys ( )
for name in pairs ( ImageControl.binding_names ) do
pcall ( mp.remove_key_binding , name )
ImageControl.binding_names [ name ] = nil
end
end
local function _activate_image_controls ( )
if ImageControl.enabled then
return
end
ImageControl.enabled = true
_set_image_property ( true )
_enable_image_section ( )
mp.osd_message ( ' Image viewer controls enabled ' , 1.2 )
_bind_image_key ( ' LEFT ' , ' image-pan-left ' , function ( ) _change_pan ( - ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' RIGHT ' , ' image-pan-right ' , function ( ) _change_pan ( ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' s ' , ' image-pan-s ' , function ( ) _change_pan ( 0 , ImageControl.pan_step ) end , { repeatable = true } )
_bind_image_key ( ' a ' , ' image-pan-a ' , function ( ) _change_pan ( ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' d ' , ' image-pan-d ' , function ( ) _change_pan ( - ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' Shift+RIGHT ' , ' image-pan-right-fine ' , function ( ) _change_pan ( ImageControl.pan_step_slow , 0 ) end , { repeatable = true } )
_bind_image_key ( ' Shift+UP ' , ' image-pan-up-fine ' , function ( ) _change_pan ( 0 , - ImageControl.pan_step_slow ) end , { repeatable = true } )
_bind_image_key ( ' Shift+DOWN ' , ' image-pan-down-fine ' , function ( ) _change_pan ( 0 , ImageControl.pan_step_slow ) end , { repeatable = true } )
_bind_image_key ( ' h ' , ' image-pan-h ' , function ( ) _change_pan ( - ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' l ' , ' image-pan-l ' , function ( ) _change_pan ( ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' j ' , ' image-pan-j ' , function ( ) _change_pan ( 0 , ImageControl.pan_step ) end , { repeatable = true } )
_bind_image_key ( ' k ' , ' image-pan-k ' , function ( ) _change_pan ( 0 , - ImageControl.pan_step ) end , { repeatable = true } )
_bind_image_key ( ' w ' , ' image-pan-w ' , function ( ) _change_pan ( 0 , - ImageControl.pan_step ) end , { repeatable = true } )
_bind_image_key ( ' s ' , ' image-pan-s ' , function ( ) _change_pan ( 0 , ImageControl.pan_step ) end , { repeatable = true } )
_bind_image_key ( ' a ' , ' image-pan-a ' , function ( ) _change_pan ( ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' d ' , ' image-pan-d ' , function ( ) _change_pan ( - ImageControl.pan_step , 0 ) end , { repeatable = true } )
_bind_image_key ( ' = ' , ' image-zoom-in ' , function ( ) _change_zoom ( ImageControl.zoom_step ) end , { repeatable = true } )
_disable_image_section ( )
_bind_image_key ( ' - ' , ' image-zoom-out ' , function ( ) _change_zoom ( - ImageControl.zoom_step ) end , { repeatable = true } )
_bind_image_key ( ' + ' , ' image-zoom-in-fine ' , function ( ) _change_zoom ( ImageControl.zoom_step_slow ) end , { repeatable = true } )
_bind_image_key ( ' _ ' , ' image-zoom-out-fine ' , function ( ) _change_zoom ( - ImageControl.zoom_step_slow ) end , { repeatable = true } )
_bind_image_key ( ' 0 ' , ' image-zoom-reset ' , _reset_pan_zoom )
_bind_image_key ( ' Space ' , ' image-status ' , function ( ) _show_image_status ( ' Image status ' ) end )
_bind_image_key ( ' f ' , ' image-screenshot ' , _capture_screenshot )
_install_q_block ( )
end
local function _deactivate_image_controls ( )
if not ImageControl.enabled then
return
end
ImageControl.enabled = false
_set_image_property ( false )
_restore_q_default ( )
_unbind_image_keys ( )
mp.osd_message ( ' Image viewer controls disabled ' , 1.0 )
mp.set_property ( ' panscan ' , 0 )
mp.set_property ( ' video-zoom ' , 0 )
mp.set_property_number ( ' video-pan-x ' , 0 )
mp.set_property_number ( ' video-pan-y ' , 0 )
mp.set_property ( ' video-align-x ' , ' 0 ' )
mp.set_property ( ' video-align-y ' , ' 0 ' )
end
local function _update_image_mode ( )
local should_image = _get_current_item_is_image ( )
if should_image then
_activate_image_controls ( )
else
_deactivate_image_controls ( )
end
end
mp.register_event ( ' file-loaded ' , function ( )
_update_image_mode ( )
end )
mp.register_event ( ' shutdown ' , function ( )
_restore_q_default ( )
end )
_update_image_mode ( )
2025-12-18 22:50:21 -08:00
local function _extract_store_hash ( target )
if type ( target ) ~= ' string ' or target == ' ' then
return nil
end
local hash = _extract_query_param ( target , ' hash ' )
local store = _extract_query_param ( target , ' store ' )
if hash and store then
local h = tostring ( hash ) : lower ( )
if h : match ( ' ^[0-9a-f]+$ ' ) and # h == 64 then
return { store = tostring ( store ) , hash = h }
end
end
return nil
end
local function _pick_folder_windows ( )
-- Native folder picker via PowerShell + WinForms.
local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]]
local res = utils.subprocess ( {
2025-12-24 02:13:21 -08:00
-- Hide the PowerShell console window (dialog still shows).
args = { ' powershell ' , ' -NoProfile ' , ' -WindowStyle ' , ' Hidden ' , ' -STA ' , ' -ExecutionPolicy ' , ' Bypass ' , ' -Command ' , ps } ,
2025-12-18 22:50:21 -08:00
cancellable = false ,
} )
if res and res.status == 0 and res.stdout then
local out = trim ( tostring ( res.stdout ) )
if out ~= ' ' then
return out
end
end
return nil
end
-- Forward declaration: used by run_pipeline_via_ipc_response before definition.
local ensure_pipeline_helper_running
local function _run_helper_request_response ( req , timeout_seconds )
2025-12-19 02:29:42 -08:00
_last_ipc_error = ' '
2025-12-18 22:50:21 -08:00
if not ensure_pipeline_helper_running ( ) then
2025-12-27 03:13:16 -08:00
local rv = tostring ( mp.get_property ( PIPELINE_READY_PROP ) or mp.get_property_native ( PIPELINE_READY_PROP ) or ' ' )
_lua_log ( ' ipc: helper not ready (ready= ' .. rv .. ' ); attempting request anyway ' )
2025-12-24 02:13:21 -08:00
_last_ipc_error = ' helper not ready '
2025-12-18 22:50:21 -08:00
end
do
2025-12-27 03:13:16 -08:00
-- Best-effort wait for heartbeat, but do not hard-fail the request.
local deadline = mp.get_time ( ) + 1.5
2025-12-18 22:50:21 -08:00
while mp.get_time ( ) < deadline do
if _is_pipeline_helper_ready ( ) then
break
end
mp.wait_event ( 0.05 )
end
if not _is_pipeline_helper_ready ( ) then
2025-12-27 03:13:16 -08:00
local rv = tostring ( mp.get_property ( PIPELINE_READY_PROP ) or mp.get_property_native ( PIPELINE_READY_PROP ) or ' ' )
_lua_log ( ' ipc: proceeding without helper heartbeat; ready= ' .. rv )
_last_ipc_error = ' helper heartbeat missing (ready= ' .. rv .. ' ) '
2025-12-18 22:50:21 -08:00
end
end
if type ( req ) ~= ' table ' then
return nil
end
local id = tostring ( req.id or ' ' )
if id == ' ' then
id = tostring ( math.floor ( mp.get_time ( ) * 1000 ) ) .. ' - ' .. tostring ( math.random ( 100000 , 999999 ) )
req.id = id
end
local label = ' '
if req.op then
label = ' op= ' .. tostring ( req.op )
elseif req.pipeline then
label = ' cmd= ' .. tostring ( req.pipeline )
else
label = ' (unknown) '
end
_lua_log ( ' ipc: send request id= ' .. tostring ( id ) .. ' ' .. label )
2025-12-19 02:29:42 -08:00
local req_json = utils.format_json ( req )
_last_ipc_last_req_json = req_json
2025-12-18 22:50:21 -08:00
mp.set_property ( PIPELINE_RESP_PROP , ' ' )
2025-12-19 02:29:42 -08:00
mp.set_property ( PIPELINE_REQ_PROP , req_json )
-- Read-back for debugging: confirms MPV accepted the property write.
local echoed = mp.get_property ( PIPELINE_REQ_PROP ) or ' '
if echoed == ' ' then
_lua_log ( ' ipc: WARNING request property echoed empty after set ' )
end
2025-12-18 22:50:21 -08:00
local deadline = mp.get_time ( ) + ( timeout_seconds or 5 )
while mp.get_time ( ) < deadline do
local resp_json = mp.get_property ( PIPELINE_RESP_PROP )
if resp_json and resp_json ~= ' ' then
2025-12-19 02:29:42 -08:00
_last_ipc_last_resp_json = resp_json
2025-12-18 22:50:21 -08:00
local ok , resp = pcall ( utils.parse_json , resp_json )
if ok and resp and resp.id == id then
_lua_log ( ' ipc: got response id= ' .. tostring ( id ) .. ' success= ' .. tostring ( resp.success ) )
return resp
end
end
mp.wait_event ( 0.05 )
end
_lua_log ( ' ipc: timeout waiting response; ' .. label )
2025-12-19 02:29:42 -08:00
_last_ipc_error = ' timeout waiting response ( ' .. label .. ' ) '
2025-12-18 22:50:21 -08:00
return nil
end
-- IPC helper: return the whole response object (stdout/stderr/error/table)
local function run_pipeline_via_ipc_response ( pipeline_cmd , seeds , timeout_seconds )
local req = { pipeline = pipeline_cmd }
if seeds then
req.seeds = seeds
end
return _run_helper_request_response ( req , timeout_seconds )
end
local function _refresh_store_cache ( timeout_seconds )
ensure_mpv_ipc_server ( )
2025-12-27 03:13:16 -08:00
-- First, try reading the pre-computed cached property (set by helper at startup).
-- This avoids a request/response timeout if observe_property isn't working.
local cached_json = mp.get_property ( ' user-data/medeia-store-choices-cached ' )
_lua_log ( ' stores: cache_read cached_json= ' .. tostring ( cached_json ) .. ' len= ' .. tostring ( cached_json and # cached_json or 0 ) )
if cached_json and cached_json ~= ' ' then
-- Try to parse as JSON (may fail if not valid JSON)
local ok , cached_resp = pcall ( utils.parse_json , cached_json )
_lua_log ( ' stores: cache_parse ok= ' .. tostring ( ok ) .. ' resp_type= ' .. tostring ( type ( cached_resp ) ) )
-- Handle both cases: parsed object OR string (if JSON lib returns string)
if ok then
-- If parse returned a string, it might still be valid JSON; try parsing again
if type ( cached_resp ) == ' string ' then
_lua_log ( ' stores: cache_parse returned string, trying again... ' )
ok , cached_resp = pcall ( utils.parse_json , cached_resp )
_lua_log ( ' stores: cache_parse retry ok= ' .. tostring ( ok ) .. ' resp_type= ' .. tostring ( type ( cached_resp ) ) )
end
-- Now check if we have a table with choices
if type ( cached_resp ) == ' table ' and type ( cached_resp.choices ) == ' table ' then
local out = { }
for _ , v in ipairs ( cached_resp.choices ) do
local name = trim ( tostring ( v or ' ' ) )
if name ~= ' ' then
out [ # out + 1 ] = name
end
end
_cached_store_names = out
_store_cache_loaded = true
local preview = ' '
if # _cached_store_names > 0 then
preview = table.concat ( _cached_store_names , ' , ' )
end
_lua_log ( ' stores: loaded ' .. tostring ( # _cached_store_names ) .. ' stores from cache: ' .. tostring ( preview ) )
return true
else
_lua_log ( ' stores: cache_parse final type mismatch resp_type= ' .. tostring ( type ( cached_resp ) ) .. ' choices_type= ' .. tostring ( cached_resp and type ( cached_resp.choices ) or ' n/a ' ) )
end
else
_lua_log ( ' stores: cache_parse failed ok= ' .. tostring ( ok ) .. ' resp= ' .. tostring ( cached_resp ) )
end
else
_lua_log ( ' stores: cache_empty cached_json= ' .. tostring ( cached_json ) )
end
-- Fallback: request fresh store-choices from helper (with timeout).
_lua_log ( ' stores: requesting store-choices via helper (fallback) ' )
2025-12-18 22:50:21 -08:00
local resp = _run_helper_request_response ( { op = ' store-choices ' } , timeout_seconds or 1 )
if not resp or not resp.success or type ( resp.choices ) ~= ' table ' then
2025-12-27 03:13:16 -08:00
_lua_log (
' stores: failed to load store choices via helper; success= '
.. tostring ( resp and resp.success or false )
.. ' choices_type= '
.. tostring ( resp and type ( resp.choices ) or ' nil ' )
.. ' stderr= '
.. tostring ( resp and resp.stderr or ' ' )
.. ' error= '
.. tostring ( resp and resp.error or ' ' )
)
2025-12-18 22:50:21 -08:00
return false
end
local out = { }
for _ , v in ipairs ( resp.choices ) do
local name = trim ( tostring ( v or ' ' ) )
if name ~= ' ' then
out [ # out + 1 ] = name
end
end
_cached_store_names = out
_store_cache_loaded = true
2025-12-27 03:13:16 -08:00
local preview = ' '
if # _cached_store_names > 0 then
preview = table.concat ( _cached_store_names , ' , ' )
end
_lua_log ( ' stores: loaded ' .. tostring ( # _cached_store_names ) .. ' stores via helper request: ' .. tostring ( preview ) )
2025-12-18 22:50:21 -08:00
return true
end
local function _uosc_open_list_picker ( menu_type , title , items )
local menu_data = {
type = menu_type ,
title = title ,
items = items or { } ,
}
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , utils.format_json ( menu_data ) )
else
_lua_log ( ' menu: uosc not available; cannot open-menu ' )
end
end
2025-12-27 03:13:16 -08:00
local function _open_store_picker ( )
_ensure_selected_store_loaded ( )
local selected = _get_selected_store ( )
local cached_count = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
local cached_preview = ' '
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
cached_preview = table.concat ( _cached_store_names , ' , ' )
end
_lua_log (
' stores: open picker selected= '
.. tostring ( selected )
.. ' cached_count= '
.. tostring ( cached_count )
.. ' cached= '
.. tostring ( cached_preview )
)
local function build_items ( )
local selected = _get_selected_store ( )
local items = { }
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
for _ , name in ipairs ( _cached_store_names ) do
name = trim ( tostring ( name or ' ' ) )
if name ~= ' ' then
local payload = { store = name }
items [ # items + 1 ] = {
title = name ,
active = ( selected ~= ' ' and name == selected ) and true or false ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-store-select ' , utils.format_json ( payload ) } ,
}
end
end
else
items [ # items + 1 ] = {
title = ' No stores found ' ,
hint = ' Configure stores in config.conf ' ,
selectable = false ,
}
end
return items
end
-- Open immediately with whatever cache we have.
_uosc_open_list_picker ( STORE_PICKER_MENU_TYPE , ' Store ' , build_items ( ) )
-- Best-effort refresh; retry briefly to avoid races where the helper isn't
-- ready/observing yet at the exact moment the menu opens.
local function attempt_refresh ( tries_left )
local before_count = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
local before_preview = ' '
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
before_preview = table.concat ( _cached_store_names , ' , ' )
end
local ok = _refresh_store_cache ( 1.2 )
local after_count = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
local after_preview = ' '
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
after_preview = table.concat ( _cached_store_names , ' , ' )
end
_lua_log (
' stores: refresh attempt ok= '
.. tostring ( ok )
.. ' before= '
.. tostring ( before_count )
.. ' after= '
.. tostring ( after_count )
.. ' after= '
.. tostring ( after_preview )
)
if after_count > 0 and ( after_count ~= before_count or after_preview ~= before_preview ) then
_lua_log ( ' stores: reopening menu (store list changed) ' )
_uosc_open_list_picker ( STORE_PICKER_MENU_TYPE , ' Store ' , build_items ( ) )
return
end
if tries_left > 0 then
mp.add_timeout ( 0.25 , function ( )
attempt_refresh ( tries_left - 1 )
end )
end
end
mp.add_timeout ( 0.05 , function ( )
attempt_refresh ( 6 )
end )
end
mp.register_script_message ( ' medeia-store-picker ' , function ( )
_open_store_picker ( )
end )
mp.register_script_message ( ' medeia-store-select ' , function ( json )
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
local store = trim ( tostring ( ev.store or ' ' ) )
if store == ' ' then
return
end
_set_selected_store ( store )
mp.osd_message ( ' Store: ' .. store , 2 )
end )
2025-12-18 22:50:21 -08:00
-- No-op handler for placeholder menu items.
mp.register_script_message ( ' medios-nop ' , function ( )
return
end )
local _pending_download = nil
local _pending_format_change = nil
2025-12-19 02:29:42 -08:00
-- Per-file state (class-like) for format caching.
local FileState = { }
FileState.__index = FileState
function FileState . new ( )
return setmetatable ( {
url = nil ,
formats = nil ,
formats_table = nil , -- back-compat alias
} , FileState )
end
function FileState : has_formats ( )
return type ( self.formats ) == ' table '
and type ( self.formats . rows ) == ' table '
and # self.formats . rows > 0
end
function FileState : set_formats ( url , tbl )
self.url = url
self.formats = tbl
self.formats_table = tbl
end
M.file = M.file or FileState.new ( )
2025-12-18 22:50:21 -08:00
-- Cache yt-dlp format lists per URL so Change Format is instant.
local _formats_cache = { }
local _formats_inflight = { }
2025-12-19 02:29:42 -08:00
local _formats_waiters = { }
local _ipc_async_busy = false
local _ipc_async_queue = { }
2025-12-18 22:50:21 -08:00
local function _is_http_url ( u )
if type ( u ) ~= ' string ' then
return false
end
return u : match ( ' ^https?:// ' ) ~= nil
end
local function _cache_formats_for_url ( url , tbl )
if type ( url ) ~= ' string ' or url == ' ' then
return
end
if type ( tbl ) ~= ' table ' then
return
end
_formats_cache [ url ] = { table = tbl , ts = mp.get_time ( ) }
2025-12-19 02:29:42 -08:00
if type ( M.file ) == ' table ' and M.file . set_formats then
M.file : set_formats ( url , tbl )
else
M.file . url = url
M.file . formats = tbl
M.file . formats_table = tbl
end
2025-12-18 22:50:21 -08:00
end
local function _get_cached_formats_table ( url )
if type ( url ) ~= ' string ' or url == ' ' then
return nil
end
local hit = _formats_cache [ url ]
if type ( hit ) == ' table ' and type ( hit.table ) == ' table ' then
return hit.table
end
return nil
end
2025-12-19 02:29:42 -08:00
local function _run_helper_request_async ( req , timeout_seconds , cb )
cb = cb or function ( ) end
if _ipc_async_busy then
_ipc_async_queue [ # _ipc_async_queue + 1 ] = { req = req , timeout = timeout_seconds , cb = cb }
return
end
_ipc_async_busy = true
local function done ( resp , err )
_ipc_async_busy = false
cb ( resp , err )
if # _ipc_async_queue > 0 then
local next_job = table.remove ( _ipc_async_queue , 1 )
-- Schedule next job slightly later to let mpv deliver any pending events.
mp.add_timeout ( 0.01 , function ( )
_run_helper_request_async ( next_job.req , next_job.timeout , next_job.cb )
end )
end
end
if type ( req ) ~= ' table ' then
done ( nil , ' invalid request ' )
return
end
ensure_mpv_ipc_server ( )
if not ensure_pipeline_helper_running ( ) then
done ( nil , ' helper not running ' )
return
end
-- Assign id.
local id = tostring ( req.id or ' ' )
if id == ' ' then
id = tostring ( math.floor ( mp.get_time ( ) * 1000 ) ) .. ' - ' .. tostring ( math.random ( 100000 , 999999 ) )
req.id = id
end
local label = ' '
if req.op then
label = ' op= ' .. tostring ( req.op )
elseif req.pipeline then
label = ' cmd= ' .. tostring ( req.pipeline )
else
label = ' (unknown) '
end
-- Wait for helper READY without blocking the UI.
local ready_deadline = mp.get_time ( ) + 3.0
local ready_timer
ready_timer = mp.add_periodic_timer ( 0.05 , function ( )
if _is_pipeline_helper_ready ( ) then
ready_timer : kill ( )
_lua_log ( ' ipc-async: send request id= ' .. tostring ( id ) .. ' ' .. label )
local req_json = utils.format_json ( req )
_last_ipc_last_req_json = req_json
mp.set_property ( PIPELINE_RESP_PROP , ' ' )
mp.set_property ( PIPELINE_REQ_PROP , req_json )
local deadline = mp.get_time ( ) + ( timeout_seconds or 5 )
local poll_timer
poll_timer = mp.add_periodic_timer ( 0.05 , function ( )
if mp.get_time ( ) >= deadline then
poll_timer : kill ( )
done ( nil , ' timeout waiting response ( ' .. label .. ' ) ' )
return
end
local resp_json = mp.get_property ( PIPELINE_RESP_PROP )
if resp_json and resp_json ~= ' ' then
_last_ipc_last_resp_json = resp_json
local ok , resp = pcall ( utils.parse_json , resp_json )
if ok and resp and resp.id == id then
poll_timer : kill ( )
_lua_log ( ' ipc-async: got response id= ' .. tostring ( id ) .. ' success= ' .. tostring ( resp.success ) )
done ( resp , nil )
end
end
end )
return
end
if mp.get_time ( ) >= ready_deadline then
ready_timer : kill ( )
done ( nil , ' helper not ready ' )
return
end
end )
end
function FileState : fetch_formats ( cb )
local url = tostring ( self.url or ' ' )
2025-12-18 22:50:21 -08:00
if url == ' ' or not _is_http_url ( url ) then
2025-12-19 02:29:42 -08:00
if cb then cb ( false , ' not a url ' ) end
2025-12-18 22:50:21 -08:00
return
end
-- Only applies to plain URLs (not store hash URLs).
if _extract_store_hash ( url ) then
2025-12-19 02:29:42 -08:00
if cb then cb ( false , ' store-hash url ' ) end
2025-12-18 22:50:21 -08:00
return
end
2025-12-19 02:29:42 -08:00
-- Cache hit.
local cached = _get_cached_formats_table ( url )
if type ( cached ) == ' table ' then
self : set_formats ( url , cached )
if cb then cb ( true , nil ) end
2025-12-18 22:50:21 -08:00
return
end
2025-12-19 02:29:42 -08:00
-- In-flight: register waiter.
2025-12-18 22:50:21 -08:00
if _formats_inflight [ url ] then
2025-12-19 02:29:42 -08:00
_formats_waiters [ url ] = _formats_waiters [ url ] or { }
if cb then table.insert ( _formats_waiters [ url ] , cb ) end
2025-12-18 22:50:21 -08:00
return
end
_formats_inflight [ url ] = true
2025-12-19 02:29:42 -08:00
_formats_waiters [ url ] = _formats_waiters [ url ] or { }
if cb then table.insert ( _formats_waiters [ url ] , cb ) end
2025-12-18 22:50:21 -08:00
2025-12-19 02:29:42 -08:00
-- Async request so the UI never blocks.
_run_helper_request_async ( { op = ' ytdlp-formats ' , data = { url = url } } , 90 , function ( resp , err )
2025-12-18 22:50:21 -08:00
_formats_inflight [ url ] = nil
2025-12-19 02:29:42 -08:00
local ok = false
local reason = err
2025-12-18 22:50:21 -08:00
if resp and resp.success and type ( resp.table ) == ' table ' then
2025-12-19 02:29:42 -08:00
ok = true
reason = nil
self : set_formats ( url , resp.table )
2025-12-18 22:50:21 -08:00
_cache_formats_for_url ( url , resp.table )
_lua_log ( ' formats: cached ' .. tostring ( ( resp.table . rows and # resp.table . rows ) or 0 ) .. ' rows for url ' )
2025-12-19 02:29:42 -08:00
else
if type ( resp ) == ' table ' then
if resp.error and tostring ( resp.error ) ~= ' ' then
reason = tostring ( resp.error )
elseif resp.stderr and tostring ( resp.stderr ) ~= ' ' then
reason = tostring ( resp.stderr )
end
end
end
local waiters = _formats_waiters [ url ] or { }
_formats_waiters [ url ] = nil
for _ , fn in ipairs ( waiters ) do
pcall ( fn , ok , reason )
2025-12-18 22:50:21 -08:00
end
end )
end
2025-12-19 02:29:42 -08:00
local function _prefetch_formats_for_url ( url )
url = tostring ( url or ' ' )
if url == ' ' or not _is_http_url ( url ) then
return
end
if type ( M.file ) == ' table ' then
M.file . url = url
if M.file . fetch_formats then
M.file : fetch_formats ( nil )
end
end
end
2025-12-18 22:50:21 -08:00
local function _open_loading_formats_menu ( title )
_uosc_open_list_picker ( DOWNLOAD_FORMAT_MENU_TYPE , title or ' Pick format ' , {
{
title = ' Loading formats… ' ,
hint = ' Fetching format list ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-nop ' , ' {} ' } ,
} ,
} )
end
2025-12-19 02:29:42 -08:00
local function _debug_dump_formatted_formats ( url , tbl , items )
local row_count = 0
if type ( tbl ) == ' table ' and type ( tbl.rows ) == ' table ' then
row_count = # tbl.rows
end
local item_count = 0
if type ( items ) == ' table ' then
item_count = # items
end
_lua_log ( ' formats-dump: url= ' .. tostring ( url or ' ' ) .. ' rows= ' .. tostring ( row_count ) .. ' menu_items= ' .. tostring ( item_count ) )
-- Dump the formatted picker items (first 30) so we can confirm the
-- list is being built and looks sane.
if type ( items ) == ' table ' then
local limit = 30
for i = 1 , math.min ( # items , limit ) do
local it = items [ i ] or { }
local title = tostring ( it.title or ' ' )
local hint = tostring ( it.hint or ' ' )
_lua_log ( ' formats-item[ ' .. tostring ( i ) .. ' ]: ' .. title .. ( hint ~= ' ' and ( ' | ' .. hint ) or ' ' ) )
end
if # items > limit then
_lua_log ( ' formats-dump: (truncated; total= ' .. tostring ( # items ) .. ' ) ' )
end
end
end
2025-12-18 22:50:21 -08:00
local function _current_ytdl_format_string ( )
-- Preferred: mpv exposes the active ytdl format string.
local fmt = trim ( tostring ( mp.get_property_native ( ' ytdl-format ' ) or ' ' ) )
if fmt ~= ' ' then
return fmt
end
-- Fallbacks: option value, or raw info if available.
local opt = trim ( tostring ( mp.get_property ( ' options/ytdl-format ' ) or ' ' ) )
if opt ~= ' ' then
return opt
end
local raw = mp.get_property_native ( ' ytdl-raw-info ' )
if type ( raw ) == ' table ' then
if raw.format_id and tostring ( raw.format_id ) ~= ' ' then
return tostring ( raw.format_id )
end
local rf = raw.requested_formats
if type ( rf ) == ' table ' then
local parts = { }
for _ , item in ipairs ( rf ) do
if type ( item ) == ' table ' and item.format_id and tostring ( item.format_id ) ~= ' ' then
parts [ # parts + 1 ] = tostring ( item.format_id )
end
end
if # parts >= 1 then
return table.concat ( parts , ' + ' )
end
end
end
return nil
end
local function _run_pipeline_detached ( pipeline_cmd )
if not pipeline_cmd or pipeline_cmd == ' ' then
return false
end
2025-12-24 02:13:21 -08:00
local resp = _run_helper_request_response ( { op = ' run-detached ' , data = { pipeline = pipeline_cmd } } , 1.0 )
return ( resp and resp.success ) and true or false
2025-12-18 22:50:21 -08:00
end
local function _open_save_location_picker_for_pending_download ( )
if type ( _pending_download ) ~= ' table ' or not _pending_download.url or not _pending_download.format then
return
end
local function build_items ( )
local items = {
{
title = ' Pick folder… ' ,
hint = ' Save to a local folder ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-download-pick-path ' , ' {} ' } ,
} ,
}
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
for _ , name in ipairs ( _cached_store_names ) do
name = trim ( tostring ( name or ' ' ) )
if name ~= ' ' then
local payload = { store = name }
items [ # items + 1 ] = {
title = name ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-download-pick-store ' , utils.format_json ( payload ) } ,
}
end
end
end
return items
end
-- Always open immediately with whatever store cache we have.
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , ' Save location ' , build_items ( ) )
-- Best-effort refresh; if it succeeds, reopen menu with stores.
mp.add_timeout ( 0.05 , function ( )
if type ( _pending_download ) ~= ' table ' or not _pending_download.url or not _pending_download.format then
return
end
local before = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
if _refresh_store_cache ( 1.5 ) then
local after = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
if after > 0 and after ~= before then
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , ' Save location ' , build_items ( ) )
end
end
end )
end
-- Prime store cache shortly after load (best-effort; picker also refreshes on-demand).
mp.add_timeout ( 0.10 , function ( )
2025-12-27 03:13:16 -08:00
pcall ( _ensure_selected_store_loaded )
2025-12-18 22:50:21 -08:00
if not _store_cache_loaded then
pcall ( _refresh_store_cache , 1.5 )
end
end )
local function _apply_ytdl_format_and_reload ( url , fmt )
if not url or url == ' ' or not fmt or fmt == ' ' then
return
end
local pos = mp.get_property_number ( ' time-pos ' )
local paused = mp.get_property_native ( ' pause ' ) and true or false
_lua_log ( ' change-format: setting options/ytdl-format= ' .. tostring ( fmt ) )
pcall ( mp.set_property , ' options/ytdl-format ' , tostring ( fmt ) )
if pos and pos > 0 then
mp.commandv ( ' loadfile ' , url , ' replace ' , ' start= ' .. tostring ( pos ) )
else
mp.commandv ( ' loadfile ' , url , ' replace ' )
end
if paused then
mp.set_property_native ( ' pause ' , true )
end
end
local function _start_download_flow_for_current ( )
local target = _current_target ( )
if not target or target == ' ' then
mp.osd_message ( ' No current item ' , 2 )
return
end
_lua_log ( ' download: current target= ' .. tostring ( target ) )
local store_hash = _extract_store_hash ( target )
if store_hash then
if not _is_windows ( ) then
mp.osd_message ( ' Download folder picker is Windows-only ' , 4 )
return
end
local folder = _pick_folder_windows ( )
if not folder or folder == ' ' then
return
end
ensure_mpv_ipc_server ( )
2025-12-20 02:12:45 -08:00
M.run_pipeline ( ' get-file -store ' .. quote_pipeline_arg ( store_hash.store ) .. ' -query ' .. quote_pipeline_arg ( ' hash: ' .. store_hash.hash ) .. ' -path ' .. quote_pipeline_arg ( folder ) )
2025-12-18 22:50:21 -08:00
mp.osd_message ( ' Download started ' , 2 )
return
end
-- Non-store URL flow: use the current yt-dlp-selected format and ask for save location.
local url = tostring ( target )
local fmt = _current_ytdl_format_string ( )
if not fmt or fmt == ' ' then
_lua_log ( ' download: could not determine current ytdl format string ' )
mp.osd_message ( ' Cannot determine current format; use Change Format first ' , 5 )
return
end
_lua_log ( ' download: using current format= ' .. tostring ( fmt ) )
_pending_download = { url = url , format = fmt }
_open_save_location_picker_for_pending_download ( )
end
mp.register_script_message ( ' medios-download-current ' , function ( )
_start_download_flow_for_current ( )
end )
mp.register_script_message ( ' medios-change-format-current ' , function ( )
local target = _current_target ( )
if not target or target == ' ' then
mp.osd_message ( ' No current item ' , 2 )
return
end
local store_hash = _extract_store_hash ( target )
if store_hash then
mp.osd_message ( ' Change Format is only for URL playback ' , 4 )
return
end
local url = tostring ( target )
2025-12-19 02:29:42 -08:00
-- Ensure file state is tracking the current URL.
if type ( M.file ) == ' table ' then
M.file . url = url
end
2025-12-18 22:50:21 -08:00
-- If formats were already prefetched for this URL, open instantly.
2025-12-19 02:29:42 -08:00
local cached_tbl = nil
if type ( M.file ) == ' table ' and type ( M.file . formats ) == ' table ' then
cached_tbl = M.file . formats
else
cached_tbl = _get_cached_formats_table ( url )
end
2025-12-18 22:50:21 -08:00
if type ( cached_tbl ) == ' table ' and type ( cached_tbl.rows ) == ' table ' and # cached_tbl.rows > 0 then
_pending_format_change = { url = url , token = ' cached ' , formats_table = cached_tbl }
local items = { }
for idx , row in ipairs ( cached_tbl.rows ) do
local cols = row.columns or { }
local id_val = ' '
local res_val = ' '
local ext_val = ' '
local size_val = ' '
for _ , c in ipairs ( cols ) do
if c.name == ' ID ' then id_val = tostring ( c.value or ' ' ) end
if c.name == ' Resolution ' then res_val = tostring ( c.value or ' ' ) end
if c.name == ' Ext ' then ext_val = tostring ( c.value or ' ' ) end
if c.name == ' Size ' then size_val = tostring ( c.value or ' ' ) end
end
local label = id_val ~= ' ' and id_val or ( ' Format ' .. tostring ( idx ) )
local hint_parts = { }
if res_val ~= ' ' and res_val ~= ' N/A ' then table.insert ( hint_parts , res_val ) end
if ext_val ~= ' ' then table.insert ( hint_parts , ext_val ) end
if size_val ~= ' ' and size_val ~= ' N/A ' then table.insert ( hint_parts , size_val ) end
local hint = table.concat ( hint_parts , ' | ' )
local payload = { index = idx }
items [ # items + 1 ] = {
title = label ,
hint = hint ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-change-format-pick ' , utils.format_json ( payload ) } ,
}
end
2025-12-19 02:29:42 -08:00
_debug_dump_formatted_formats ( url , cached_tbl , items )
2025-12-18 22:50:21 -08:00
_uosc_open_list_picker ( DOWNLOAD_FORMAT_MENU_TYPE , ' Change format ' , items )
return
end
local token = tostring ( math.floor ( mp.get_time ( ) * 1000 ) ) .. ' - ' .. tostring ( math.random ( 100000 , 999999 ) )
_pending_format_change = { url = url , token = token }
_open_loading_formats_menu ( ' Change format ' )
2025-12-19 02:29:42 -08:00
-- Non-blocking: ask the per-file state to fetch formats in the background.
if type ( M.file ) == ' table ' and M.file . fetch_formats then
_lua_log ( ' change-format: formats not cached yet; fetching in background ' )
M.file : fetch_formats ( function ( ok , err )
if type ( _pending_format_change ) ~= ' table ' or _pending_format_change.token ~= token then
return
end
if not ok then
local msg2 = tostring ( err or ' ' )
if msg2 == ' ' then
msg2 = ' unknown '
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
_lua_log ( ' change-format: formats failed: ' .. msg2 )
mp.osd_message ( ' Failed to load format list: ' .. msg2 , 7 )
_uosc_open_list_picker ( DOWNLOAD_FORMAT_MENU_TYPE , ' Change format ' , {
{
title = ' Failed to load format list ' ,
hint = msg2 ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-nop ' , ' {} ' } ,
} ,
} )
return
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
local tbl = ( type ( M.file . formats ) == ' table ' ) and M.file . formats or _get_cached_formats_table ( url )
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' or # tbl.rows == 0 then
mp.osd_message ( ' No formats available ' , 4 )
return
2025-12-18 22:50:21 -08:00
end
2025-12-19 02:29:42 -08:00
local items = { }
for idx , row in ipairs ( tbl.rows ) do
local cols = row.columns or { }
local id_val = ' '
local res_val = ' '
local ext_val = ' '
local size_val = ' '
for _ , c in ipairs ( cols ) do
if c.name == ' ID ' then id_val = tostring ( c.value or ' ' ) end
if c.name == ' Resolution ' then res_val = tostring ( c.value or ' ' ) end
if c.name == ' Ext ' then ext_val = tostring ( c.value or ' ' ) end
if c.name == ' Size ' then size_val = tostring ( c.value or ' ' ) end
end
local label = id_val ~= ' ' and id_val or ( ' Format ' .. tostring ( idx ) )
local hint_parts = { }
if res_val ~= ' ' and res_val ~= ' N/A ' then table.insert ( hint_parts , res_val ) end
if ext_val ~= ' ' then table.insert ( hint_parts , ext_val ) end
if size_val ~= ' ' and size_val ~= ' N/A ' then table.insert ( hint_parts , size_val ) end
local hint = table.concat ( hint_parts , ' | ' )
local payload = { index = idx }
items [ # items + 1 ] = {
title = label ,
hint = hint ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-change-format-pick ' , utils.format_json ( payload ) } ,
}
end
2025-12-18 22:50:21 -08:00
2025-12-19 02:29:42 -08:00
_pending_format_change.formats_table = tbl
_debug_dump_formatted_formats ( url , tbl , items )
_uosc_open_list_picker ( DOWNLOAD_FORMAT_MENU_TYPE , ' Change format ' , items )
end )
end
2025-12-18 22:50:21 -08:00
end )
-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.
mp.register_event ( ' file-loaded ' , function ( )
local target = _current_target ( )
if not target or target == ' ' then
return
end
local url = tostring ( target )
if not _is_http_url ( url ) then
return
end
_prefetch_formats_for_url ( url )
end )
mp.register_script_message ( ' medios-change-format-pick ' , function ( json )
if type ( _pending_format_change ) ~= ' table ' or not _pending_format_change.url then
return
end
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
local idx = tonumber ( ev.index or 0 ) or 0
if idx <= 0 then
return
end
local tbl = _pending_format_change.formats_table
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' or not tbl.rows [ idx ] then
return
end
local row = tbl.rows [ idx ]
local sel = row.selection_args
local fmt = nil
if type ( sel ) == ' table ' then
for i = 1 , # sel do
if tostring ( sel [ i ] ) == ' -format ' and sel [ i + 1 ] then
fmt = tostring ( sel [ i + 1 ] )
break
end
end
end
if not fmt or fmt == ' ' then
mp.osd_message ( ' Invalid format selection ' , 3 )
return
end
local url = tostring ( _pending_format_change.url )
_pending_format_change = nil
_apply_ytdl_format_and_reload ( url , fmt )
end )
mp.register_script_message ( ' medios-download-pick-store ' , function ( json )
if type ( _pending_download ) ~= ' table ' or not _pending_download.url or not _pending_download.format then
return
end
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
local store = trim ( tostring ( ev.store or ' ' ) )
if store == ' ' then
return
end
local url = tostring ( _pending_download.url )
local fmt = tostring ( _pending_download.format )
2026-01-01 20:37:27 -08:00
local pipeline_cmd = ' download-file -url ' .. quote_pipeline_arg ( url ) .. ' -format ' .. quote_pipeline_arg ( fmt )
2025-12-18 22:50:21 -08:00
.. ' | 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 )
2026-01-01 20:37:27 -08:00
local pipeline_cmd = ' download-file -url ' .. quote_pipeline_arg ( url ) .. ' -format ' .. quote_pipeline_arg ( fmt )
2025-12-18 22:50:21 -08:00
.. ' | add-file -path ' .. quote_pipeline_arg ( folder )
if not _run_pipeline_detached ( pipeline_cmd ) then
M.run_pipeline ( pipeline_cmd )
end
mp.osd_message ( ' Download started ' , 3 )
_pending_download = nil
end )
ensure_pipeline_helper_running = function ( )
2025-12-24 02:13:21 -08:00
-- IMPORTANT: do NOT spawn Python from inside mpv.
-- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows
-- no-console flags; spawning here can flash a console window.
return _is_pipeline_helper_ready ( ) and true or false
2025-12-17 17:42:46 -08:00
end
local function run_pipeline_via_ipc ( pipeline_cmd , seeds , timeout_seconds )
if not ensure_pipeline_helper_running ( ) then
return nil
end
-- Avoid a race where we send the request before the helper has connected
-- and installed its property observer, which would cause a timeout and
-- force a noisy CLI fallback.
do
local deadline = mp.get_time ( ) + 1.0
while mp.get_time ( ) < deadline do
2025-12-18 22:50:21 -08:00
if _is_pipeline_helper_ready ( ) then
2025-12-17 17:42:46 -08:00
break
end
mp.wait_event ( 0.05 )
end
2025-12-18 22:50:21 -08:00
if not _is_pipeline_helper_ready ( ) then
2025-12-17 17:42:46 -08:00
_pipeline_helper_started = false
return nil
end
end
local id = tostring ( math.floor ( mp.get_time ( ) * 1000 ) ) .. ' - ' .. tostring ( math.random ( 100000 , 999999 ) )
local req = { id = id , pipeline = pipeline_cmd }
if seeds then
req.seeds = seeds
end
-- Clear any previous response to reduce chances of reading stale data.
mp.set_property ( PIPELINE_RESP_PROP , ' ' )
mp.set_property ( PIPELINE_REQ_PROP , utils.format_json ( req ) )
local deadline = mp.get_time ( ) + ( timeout_seconds or 5 )
while mp.get_time ( ) < deadline do
local resp_json = mp.get_property ( PIPELINE_RESP_PROP )
if resp_json and resp_json ~= ' ' then
local ok , resp = pcall ( utils.parse_json , resp_json )
if ok and resp and resp.id == id then
if resp.success then
return resp.stdout or ' '
end
local details = ' '
if resp.error and tostring ( resp.error ) ~= ' ' then
details = tostring ( resp.error )
end
if resp.stderr and tostring ( resp.stderr ) ~= ' ' then
if details ~= ' ' then
details = details .. " \n "
end
details = details .. tostring ( resp.stderr )
end
local log_path = resp.log_path
if log_path and tostring ( log_path ) ~= ' ' then
details = ( details ~= ' ' and ( details .. " \n " ) or ' ' ) .. ' Log: ' .. tostring ( log_path )
end
return nil , ( details ~= ' ' and details or ' unknown ' )
end
end
mp.wait_event ( 0.05 )
end
-- Helper may have crashed or never started; allow retry on next call.
_pipeline_helper_started = false
return nil
end
2025-11-27 10:59:01 -08:00
-- Detect CLI path
2025-12-07 00:21:30 -08:00
local function detect_script_dir ( )
local dir = mp.get_script_directory ( )
if dir and dir ~= " " then return dir end
-- Fallback to debug info path
local src = debug.getinfo ( 1 , " S " ) . source
if src and src : sub ( 1 , 1 ) == " @ " then
local path = src : sub ( 2 )
local parent = path : match ( " (.*)[/ \\ ] " )
if parent and parent ~= " " then
return parent
end
end
-- Fallback to working directory
local cwd = utils.getcwd ( )
if cwd and cwd ~= " " then return cwd end
return nil
end
local script_dir = detect_script_dir ( ) or " "
2025-11-27 10:59:01 -08:00
if not opts.cli_path then
2025-12-17 17:42:46 -08:00
-- Try to locate CLI.py by walking up from this script directory.
-- Typical layout here is: <repo>/MPV/LUA/main.lua, and <repo>/CLI.py
opts.cli_path = find_file_upwards ( script_dir , " CLI.py " , 6 ) or " CLI.py "
2025-11-27 10:59:01 -08:00
end
2025-12-27 03:13:16 -08:00
-- Clean API wrapper for executing Python functions from Lua
local function _call_mpv_api ( request )
-- Call the MPV Lua API (mpv_lua_api.py) with a JSON request.
-- Returns: JSON-decoded response object with {success, stdout, stderr, error, ...}
local request_json = utils.format_json ( request )
-- Try to get log file path; skip if not available
local log_file = ' '
local home = os.getenv ( ' USERPROFILE ' ) or os.getenv ( ' HOME ' ) or ' '
if home ~= ' ' then
log_file = home .. ' /../../medios/Medios-Macina/Log/medeia-mpv-helper.log '
2025-12-17 17:42:46 -08:00
end
2025-12-27 03:13:16 -08:00
_lua_log ( ' api: calling mpv_lua_api cmd= ' .. tostring ( request.cmd ) )
local python_exe = _resolve_python_exe ( true )
if not python_exe or python_exe == ' ' then
_lua_log ( ' api: FAILED - no python exe ' )
return { success = false , error = ' could not find Python ' }
end
-- Try to locate API script
local api_script = nil
local script_dir = mp.get_script_directory ( )
if script_dir and script_dir ~= ' ' then
api_script = script_dir .. ' /mpv_lua_api.py '
if not utils.file_info ( api_script ) then
api_script = script_dir .. ' /../mpv_lua_api.py '
end
end
if not api_script or api_script == ' ' or not utils.file_info ( api_script ) then
-- Fallback: try absolute path
local repo_root = os.getenv ( ' USERPROFILE ' )
if repo_root then
api_script = repo_root .. ' /../../../medios/Medios-Macina/MPV/mpv_lua_api.py '
end
end
if not api_script or api_script == ' ' then
_lua_log ( ' api: FAILED - could not locate mpv_lua_api.py ' )
return { success = false , error = ' could not locate mpv_lua_api.py ' }
end
_lua_log ( ' api: python= ' .. tostring ( python_exe ) .. ' script= ' .. tostring ( api_script ) )
local res = utils.subprocess ( {
args = { python_exe , api_script , request_json , log_file } ,
cancellable = false ,
} )
if res and res.status == 0 and res.stdout then
local ok , response = pcall ( utils.parse_json , res.stdout )
if ok and response then
_lua_log ( ' api: response success= ' .. tostring ( response.success ) )
return response
else
_lua_log ( ' api: failed to parse response: ' .. tostring ( res.stdout ) )
return { success = false , error = ' malformed response ' , stdout = res.stdout }
end
else
local stderr = res and res.stderr or ' unknown error '
_lua_log ( ' api: subprocess failed status= ' .. tostring ( res and res.status or ' nil ' ) .. ' stderr= ' .. stderr )
return { success = false , error = stderr }
2025-12-17 17:42:46 -08:00
end
2025-11-27 10:59:01 -08:00
end
-- Helper to run pipeline and parse JSON output
function M . run_pipeline_json ( pipeline_cmd , seeds )
-- Append | output-json if not present
if not pipeline_cmd : match ( " output%-json$ " ) then
pipeline_cmd = pipeline_cmd .. " | output-json "
end
local output = M.run_pipeline ( pipeline_cmd , seeds )
if output then
local ok , data = pcall ( utils.parse_json , output )
if ok then
return data
else
2025-12-18 22:50:21 -08:00
_lua_log ( " Failed to parse JSON: " .. output )
2025-11-27 10:59:01 -08:00
return nil
end
end
return nil
end
-- Command: Get info for current file
function M . get_file_info ( )
local path = mp.get_property ( " path " )
if not path then return end
-- We can pass the path as a seed item
local seed = { { path = path } }
-- Run pipeline: get-metadata
local data = M.run_pipeline_json ( " get-metadata " , seed )
if data then
-- Display metadata
2025-12-18 22:50:21 -08:00
_lua_log ( " Metadata: " .. utils.format_json ( data ) )
2025-11-27 10:59:01 -08:00
mp.osd_message ( " Metadata loaded (check console) " , 3 )
end
end
-- Command: Delete current file
function M . delete_current_file ( )
local path = mp.get_property ( " path " )
if not path then return end
local seed = { { path = path } }
M.run_pipeline ( " delete-file " , seed )
mp.osd_message ( " File deleted " , 3 )
mp.command ( " playlist-next " )
end
2025-12-17 17:42:46 -08:00
-- Command: Load a URL via pipeline (Ctrl+Enter in prompt)
function M . open_load_url_prompt ( )
local menu_data = {
type = LOAD_URL_MENU_TYPE ,
title = ' Load URL ' ,
search_style = ' palette ' ,
search_debounce = ' submit ' ,
on_search = ' callback ' ,
footnote = ' Paste/type URL, then Ctrl+Enter to load. ' ,
callback = { mp.get_script_name ( ) , ' medios-load-url-event ' } ,
items = { } ,
}
local json = utils.format_json ( menu_data )
2025-12-18 22:50:21 -08:00
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , json )
else
_lua_log ( ' menu: uosc not available; cannot open-menu ' )
end
2025-12-17 17:42:46 -08:00
end
2025-12-23 16:36:39 -08:00
-- Open the command submenu with tailored cmdlets (screenshot, clip, trim prompt)
function M . open_cmd_menu ( )
local items = {
{
title = ' Screenshot ' ,
hint = ' Capture a screenshot ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-cmd-exec ' , utils.format_json ( { cmd = ' screenshot ' } ) } ,
} ,
{
title = ' Capture clip marker ' ,
hint = ' Place a clip marker at current time ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-cmd-exec ' , utils.format_json ( { cmd = ' clip ' } ) } ,
} ,
{
title = ' Trim file ' ,
hint = ' Trim current file (prompt for range) ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-cmd-exec ' , utils.format_json ( { cmd = ' trim ' } ) } ,
} ,
}
local menu_data = {
type = CMD_MENU_TYPE ,
title = ' Cmd ' ,
search_style = ' palette ' ,
search_debounce = ' submit ' ,
footnote = ' Type to filter or pick a command ' ,
items = items ,
}
local json = utils.format_json ( menu_data )
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , json )
else
_lua_log ( ' menu: uosc not available; cannot open cmd menu ' )
end
end
-- Prompt for trim range via an input box and callback
local function _start_trim_with_range ( range )
2025-12-27 03:13:16 -08:00
_lua_log ( ' === TRIM START: range= ' .. tostring ( range ) )
mp.osd_message ( ' Trimming... ' , 10 )
-- Load the trim module for direct FFmpeg trimming
local script_dir = mp.get_script_directory ( )
_lua_log ( ' trim: script_dir= ' .. tostring ( script_dir ) )
-- Try multiple locations for trim.lua
local trim_paths = { }
if script_dir and script_dir ~= ' ' then
table.insert ( trim_paths , script_dir .. ' /trim.lua ' )
table.insert ( trim_paths , script_dir .. ' /LUA/trim.lua ' ) -- if called from parent
table.insert ( trim_paths , script_dir .. ' /../trim.lua ' )
end
-- Also try absolute path
table.insert ( trim_paths , ' /medios/Medios-Macina/MPV/LUA/trim.lua ' )
table.insert ( trim_paths , ' C:/medios/Medios-Macina/MPV/LUA/trim.lua ' )
local trim_module = nil
local load_err = nil
for _ , trim_path in ipairs ( trim_paths ) do
_lua_log ( ' trim: trying path= ' .. trim_path )
local ok , result = pcall ( loadfile , trim_path )
if ok and result then
trim_module = result ( )
_lua_log ( ' trim: loaded successfully from ' .. trim_path )
break
else
load_err = tostring ( result or ' unknown error ' )
_lua_log ( ' trim: failed to load from ' .. trim_path .. ' ( ' .. load_err .. ' ) ' )
end
end
if not trim_module or not trim_module.trim_file then
mp.osd_message ( ' ERROR: Could not load trim module from any path ' , 3 )
_lua_log ( ' trim: FAILED - all paths exhausted, last error= ' .. tostring ( load_err ) )
return
end
2025-12-23 16:36:39 -08:00
range = trim ( tostring ( range or ' ' ) )
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim: after_trim range= ' .. tostring ( range ) )
2025-12-23 16:36:39 -08:00
if range == ' ' then
mp.osd_message ( ' Trim cancelled (no range provided) ' , 3 )
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim: CANCELLED - empty range ' )
2025-12-23 16:36:39 -08:00
return
end
local target = _current_target ( )
if not target or target == ' ' then
mp.osd_message ( ' No file to trim ' , 3 )
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim: FAILED - no target ' )
2025-12-23 16:36:39 -08:00
return
end
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim: target= ' .. tostring ( target ) )
2025-12-23 16:36:39 -08:00
local store_hash = _extract_store_hash ( target )
2025-12-27 03:13:16 -08:00
if store_hash then
_lua_log ( ' trim: store_hash detected store= ' .. tostring ( store_hash.store ) .. ' hash= ' .. tostring ( store_hash.hash ) )
else
_lua_log ( ' trim: store_hash=nil (local file) ' )
end
-- Get the selected store (this reads from saved config or mpv property)
_ensure_selected_store_loaded ( )
local selected_store = _get_selected_store ( )
-- Strip any existing quotes from the store name
selected_store = selected_store : gsub ( ' ^" ' , ' ' ) : gsub ( ' "$ ' , ' ' )
_lua_log ( ' trim: selected_store= ' .. tostring ( selected_store or ' NONE ' ) )
_lua_log ( ' trim: _cached_store_names= ' .. tostring ( _cached_store_names and # _cached_store_names or 0 ) )
_lua_log ( ' trim: _selected_store_index= ' .. tostring ( _selected_store_index or ' nil ' ) )
2025-12-23 16:36:39 -08:00
local stream = trim ( tostring ( mp.get_property ( ' stream-open-filename ' ) or ' ' ) )
if stream == ' ' then
stream = tostring ( target )
end
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim: stream= ' .. tostring ( stream ) )
2025-12-23 16:36:39 -08:00
2025-12-27 03:13:16 -08:00
local title = trim ( tostring ( mp.get_property ( ' media-title ' ) or ' ' ) )
if title == ' ' then
title = ' clip '
end
_lua_log ( ' trim: title= ' .. tostring ( title ) )
-- ===== TRIM IN LUA USING FFMPEG =====
mp.osd_message ( ' Starting FFmpeg trim... ' , 1 )
_lua_log ( ' trim: calling trim_module.trim_file with range= ' .. range )
-- Get temp directory from config or use default
local temp_dir = mp.get_property ( ' user-data/medeia-config-temp ' ) or os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or ' /tmp '
_lua_log ( ' trim: using temp_dir= ' .. temp_dir )
local success , output_path , error_msg = trim_module.trim_file ( stream , range , temp_dir )
if not success then
mp.osd_message ( ' Trim failed: ' .. error_msg , 3 )
_lua_log ( ' trim: FAILED - ' .. error_msg )
return
end
_lua_log ( ' trim: FFmpeg SUCCESS - output_path= ' .. output_path )
mp.osd_message ( ' Trim complete, uploading... ' , 2 )
-- ===== UPLOAD TO PYTHON FOR STORAGE AND METADATA =====
local pipeline_cmd = nil
_lua_log ( ' trim: === BUILDING UPLOAD PIPELINE === ' )
_lua_log ( ' trim: store_hash= ' .. tostring ( store_hash and ( store_hash.store .. ' / ' .. store_hash.hash ) or ' nil ' ) )
_lua_log ( ' trim: selected_store= ' .. tostring ( selected_store or ' nil ' ) )
2025-12-23 16:36:39 -08:00
if store_hash then
2025-12-27 03:13:16 -08:00
-- Original file is from a store - set relationship to it
_lua_log ( ' trim: building store file pipeline (original from store) ' )
if selected_store then
pipeline_cmd =
' get-tag -emit -store ' .. quote_pipeline_arg ( store_hash.store ) ..
' -query ' .. quote_pipeline_arg ( ' hash: ' .. store_hash.hash ) ..
' | add-file -path ' .. quote_pipeline_arg ( output_path ) ..
' -store " ' .. selected_store .. ' " ' ..
' | add-relationship -store " ' .. selected_store .. ' " ' ..
' -to-hash ' .. quote_pipeline_arg ( store_hash.hash )
else
pipeline_cmd =
' get-tag -emit -store ' .. quote_pipeline_arg ( store_hash.store ) ..
' -query ' .. quote_pipeline_arg ( ' hash: ' .. store_hash.hash ) ..
' | add-file -path ' .. quote_pipeline_arg ( output_path ) ..
' -store " ' .. store_hash.store .. ' " ' ..
' | add-relationship -store " ' .. store_hash.store .. ' " ' ..
' -to-hash ' .. quote_pipeline_arg ( store_hash.hash )
end
2025-12-23 16:36:39 -08:00
else
2025-12-27 03:13:16 -08:00
-- Local file: save to selected store if available
_lua_log ( ' trim: local file pipeline (not from store) ' )
if selected_store then
_lua_log ( ' trim: building add-file command to selected_store= ' .. selected_store )
-- Don't add title if empty - the file path will be used as title by default
pipeline_cmd = ' add-file -path ' .. quote_pipeline_arg ( output_path ) ..
' -store " ' .. selected_store .. ' " '
_lua_log ( ' trim: pipeline_cmd= ' .. pipeline_cmd )
2025-12-23 16:36:39 -08:00
else
2025-12-27 03:13:16 -08:00
mp.osd_message ( ' Trim complete: ' .. output_path , 5 )
_lua_log ( ' trim: no store selected, trim complete at ' .. output_path )
return
2025-12-23 16:36:39 -08:00
end
end
2025-12-27 03:13:16 -08:00
if not pipeline_cmd or pipeline_cmd == ' ' then
mp.osd_message ( ' Trim error: could not build upload command ' , 3 )
_lua_log ( ' trim: FAILED - empty pipeline_cmd ' )
return
end
_lua_log ( ' trim: final upload_cmd= ' .. pipeline_cmd )
_lua_log ( ' trim: === CALLING API FOR UPLOAD === ' )
-- Call the API to handle metadata/storage
local response = _call_mpv_api ( {
cmd = ' execute_pipeline ' ,
pipeline = pipeline_cmd ,
} )
_lua_log ( ' trim: api response success= ' .. tostring ( response.success ) )
_lua_log ( ' trim: api response error= ' .. tostring ( response.error or ' nil ' ) )
_lua_log ( ' trim: api response stderr= ' .. tostring ( response.stderr or ' nil ' ) )
_lua_log ( ' trim: api response returncode= ' .. tostring ( response.returncode or ' nil ' ) )
if response.stderr and response.stderr ~= ' ' then
_lua_log ( ' trim: STDERR OUTPUT: ' .. response.stderr )
end
if response.success then
local msg = ' Trim and upload completed '
if selected_store then
msg = msg .. ' (store: ' .. selected_store .. ' ) '
end
mp.osd_message ( msg , 5 )
_lua_log ( ' trim: SUCCESS - ' .. msg )
else
local err_msg = response.error or response.stderr or ' unknown error '
mp.osd_message ( ' Upload failed: ' .. err_msg , 5 )
_lua_log ( ' trim: upload FAILED - ' .. err_msg )
2025-12-23 16:36:39 -08:00
end
end
function M . open_trim_prompt ( )
2025-12-27 03:13:16 -08:00
_lua_log ( ' === OPEN_TRIM_PROMPT called ' )
2025-12-23 16:36:39 -08:00
local marker_range = _get_trim_range_from_clip_markers ( )
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim_prompt: marker_range= ' .. tostring ( marker_range or ' NONE ' ) )
2025-12-23 16:36:39 -08:00
if marker_range then
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim_prompt: using auto-detected markers, starting trim ' )
mp.osd_message ( ' Using clip markers: ' .. marker_range , 2 )
2025-12-23 16:36:39 -08:00
_start_trim_with_range ( marker_range )
return
end
2025-12-27 03:13:16 -08:00
_lua_log ( ' trim_prompt: no clip markers detected, showing prompt ' )
mp.osd_message ( ' Set 2 clip markers with the marker button, or enter range manually ' , 3 )
local selected_store = _cached_store_names and # _cached_store_names > 0 and _selected_store_index
and _cached_store_names [ _selected_store_index ] or nil
local store_hint = selected_store and ' (saving to: ' .. selected_store .. ' ) ' or ' (no store selected; will save locally) '
2025-12-23 16:36:39 -08:00
local menu_data = {
type = TRIM_PROMPT_MENU_TYPE ,
title = ' Trim file ' ,
search_style = ' palette ' ,
search_debounce = ' submit ' ,
on_search = ' callback ' ,
2025-12-27 03:13:16 -08:00
footnote = " Enter time range (e.g. '00:03:45-00:03:55' or '1h3m-1h10m30s') and press Enter " .. store_hint ,
2025-12-23 16:36:39 -08:00
callback = { mp.get_script_name ( ) , ' medios-trim-run ' } ,
items = {
{
title = ' Enter range... ' ,
hint = ' Type range and press Enter ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-trim-run ' } ,
}
}
}
local json = utils.format_json ( menu_data )
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , json )
else
_lua_log ( ' menu: uosc not available; cannot open trim prompt ' )
end
end
-- Handlers for the command submenu
mp.register_script_message ( ' medios-open-cmd ' , function ( )
M.open_cmd_menu ( )
end )
mp.register_script_message ( ' medios-cmd-exec ' , function ( json )
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
local cmd = trim ( tostring ( ev.cmd or ' ' ) )
if cmd == ' screenshot ' then
_capture_screenshot ( )
elseif cmd == ' clip ' then
_capture_clip ( )
elseif cmd == ' trim ' then
M.open_trim_prompt ( )
else
mp.osd_message ( ' Unknown cmd ' .. tostring ( cmd ) , 2 )
end
end )
mp.register_script_message ( ' medios-trim-run ' , function ( json )
local ok , ev = pcall ( utils.parse_json , json )
local range = nil
if ok and type ( ev ) == ' table ' then
if ev.type == ' search ' then
range = trim ( tostring ( ev.query or ' ' ) )
end
end
_start_trim_with_range ( range )
end )
2025-12-17 17:42:46 -08:00
mp.register_script_message ( ' medios-load-url ' , function ( )
M.open_load_url_prompt ( )
end )
mp.register_script_message ( ' medios-load-url-event ' , function ( json )
local ok , event = pcall ( utils.parse_json , json )
if not ok or type ( event ) ~= ' table ' then
return
end
if event.type ~= ' search ' then
return
end
local url = trim ( tostring ( event.query or ' ' ) )
if url == ' ' then
return
end
2025-12-18 22:50:21 -08:00
ensure_mpv_ipc_server ( )
local out = M.run_pipeline ( ' .pipe -url ' .. quote_pipeline_arg ( url ) .. ' -play ' )
2025-12-17 17:42:46 -08:00
if out ~= nil then
2025-12-18 22:50:21 -08:00
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , LOAD_URL_MENU_TYPE )
else
_lua_log ( ' menu: uosc not available; cannot close-menu ' )
end
2025-12-17 17:42:46 -08:00
end
end )
2025-11-27 10:59:01 -08:00
-- Menu integration with UOSC
function M . show_menu ( )
local menu_data = {
title = " Medios Macina " ,
items = {
{ title = " Get Metadata " , value = " script-binding medios-info " , hint = " Ctrl+i " } ,
{ title = " Delete File " , value = " script-binding medios-delete " , hint = " Ctrl+Del " } ,
2025-12-17 17:42:46 -08:00
{ title = " Load URL " , value = { " script-message-to " , mp.get_script_name ( ) , " medios-load-url " } } ,
2025-12-23 16:36:39 -08:00
{ title = " Cmd " , value = { " script-message-to " , mp.get_script_name ( ) , " medios-open-cmd " } , hint = " Run quick commands (screenshot, trim, etc) " } ,
2025-12-18 22:50:21 -08:00
{ title = " Download " , value = { " script-message-to " , mp.get_script_name ( ) , " medios-download-current " } } ,
{ title = " Change Format " , value = { " script-message-to " , mp.get_script_name ( ) , " medios-change-format-current " } } ,
2025-11-27 10:59:01 -08:00
}
}
local json = utils.format_json ( menu_data )
2025-12-18 22:50:21 -08:00
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , json )
else
_lua_log ( ' menu: uosc not available; cannot open-menu ' )
end
2025-11-27 10:59:01 -08:00
end
-- Keybindings
mp.add_key_binding ( " m " , " medios-menu " , M.show_menu )
mp.add_key_binding ( " mbtn_right " , " medios-menu-right-click " , M.show_menu )
mp.add_key_binding ( " ctrl+i " , " medios-info " , M.get_file_info )
mp.add_key_binding ( " ctrl+del " , " medios-delete " , M.delete_current_file )
2025-12-12 21:55:38 -08:00
-- Lyrics toggle (requested: 'L')
mp.add_key_binding ( " l " , " medeia-lyric-toggle " , lyric_toggle )
mp.add_key_binding ( " L " , " medeia-lyric-toggle-shift " , lyric_toggle )
2025-12-24 02:13:21 -08:00
2025-12-23 16:36:39 -08:00
2025-12-17 17:42:46 -08:00
-- Start the persistent pipeline helper eagerly at launch.
-- This avoids spawning Python per command and works cross-platform via MPV IPC.
mp.add_timeout ( 0 , function ( )
2025-12-18 22:50:21 -08:00
pcall ( ensure_mpv_ipc_server )
pcall ( _lua_log , ' medeia-lua loaded version= ' .. MEDEIA_LUA_VERSION )
2025-12-27 06:05:07 -08:00
-- Load optional modules (kept in separate files).
pcall ( function ( )
local script_dir = mp.get_script_directory ( ) or ' '
local candidates = { }
if script_dir ~= ' ' then
table.insert ( candidates , script_dir .. ' /sleep_timer.lua ' )
table.insert ( candidates , script_dir .. ' /LUA/sleep_timer.lua ' )
table.insert ( candidates , script_dir .. ' /../sleep_timer.lua ' )
end
table.insert ( candidates , ' C:/medios/Medios-Macina/MPV/LUA/sleep_timer.lua ' )
for _ , p in ipairs ( candidates ) do
local ok , chunk = pcall ( loadfile , p )
if ok and chunk then
pcall ( chunk )
break
end
end
end )
2025-12-17 17:42:46 -08:00
end )
2025-11-27 10:59:01 -08:00
return M