2025-11-27 10:59:01 -08:00
local mp = require ' mp '
local utils = require ' mp.utils '
local msg = require ' mp.msg '
local M = { }
2026-03-22 22:41:56 -07:00
local MEDEIA_LUA_VERSION = ' 2026-03-23.1 '
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 '
2026-03-18 12:24:37 -07:00
local SCREENSHOT_TAG_MENU_TYPE = ' medeia_screenshot_tags '
2026-03-18 20:17:28 -07:00
local SCREENSHOT_SAVE_JOB_TIMEOUT = 120
local SCREENSHOT_SAVE_JOB_POLL_INTERVAL = 0.75
2025-12-18 22:50:21 -08:00
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 '
2026-03-15 01:51:10 -07:00
local SLEEP_PROMPT_MENU_TYPE = ' medeia_sleep_timer_prompt '
local _sleep_timer = nil
2025-12-23 16:36:39 -08:00
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 '
2026-03-16 04:43:46 -07:00
local CURRENT_WEB_URL_PROP = ' user-data/medeia-current-web-url '
2026-03-21 15:12:52 -07:00
local _pipeline_progress_ui = {
overlay = nil ,
hide_token = 0 ,
title = ' ' ,
summary = ' ' ,
detail = ' ' ,
}
function _pipeline_progress_ui . trim ( text )
return tostring ( text or ' ' ) : gsub ( ' ^%s+ ' , ' ' ) : gsub ( ' %s+$ ' , ' ' )
end
function _pipeline_progress_ui . ass_escape ( text )
text = tostring ( text or ' ' )
text = text : gsub ( ' \\ ' , ' \\ \\ ' )
text = text : gsub ( ' { ' , ' \\ { ' )
text = text : gsub ( ' } ' , ' \\ } ' )
text = text : gsub ( ' \n ' , ' \\ N ' )
return text
end
function _pipeline_progress_ui . truncate ( text , max_len )
text = _pipeline_progress_ui.trim ( text )
max_len = tonumber ( max_len or 0 ) or 0
if max_len <= 0 or # text <= max_len then
return text
end
if max_len <= 3 then
return text : sub ( 1 , max_len )
end
return text : sub ( 1 , max_len - 3 ) .. ' ... '
end
function _pipeline_progress_ui . kind_title ( kind )
kind = _pipeline_progress_ui.trim ( kind ) : lower ( )
if kind == ' mpv-download ' then
return ' Download '
end
if kind == ' mpv-screenshot ' then
return ' Screenshot '
end
return ' Pipeline '
end
function _pipeline_progress_ui . ensure_overlay ( )
if _pipeline_progress_ui.overlay then
return _pipeline_progress_ui.overlay
end
local ok , overlay = pcall ( mp.create_osd_overlay , ' ass-events ' )
if ok and overlay then
_pipeline_progress_ui.overlay = overlay
end
return _pipeline_progress_ui.overlay
end
function _pipeline_progress_ui . cancel_hide ( )
_pipeline_progress_ui.hide_token = ( _pipeline_progress_ui.hide_token or 0 ) + 1
end
function _pipeline_progress_ui . render ( )
local overlay = _pipeline_progress_ui.ensure_overlay ( )
if not overlay then
return
end
local width , height = 1280 , 720
local ok , w , h = pcall ( mp.get_osd_size )
if ok and tonumber ( w or 0 ) and tonumber ( h or 0 ) and w > 0 and h > 0 then
width = math.floor ( w )
height = math.floor ( h )
end
overlay.res_x = width
overlay.res_y = height
if _pipeline_progress_ui.summary == ' ' and _pipeline_progress_ui.detail == ' ' then
overlay.data = ' '
overlay : update ( )
return
end
local title = _pipeline_progress_ui.truncate ( _pipeline_progress_ui.title ~= ' ' and _pipeline_progress_ui.title or ' Pipeline ' , 42 )
local summary = _pipeline_progress_ui.truncate ( _pipeline_progress_ui.summary , 72 )
local detail = _pipeline_progress_ui.truncate ( _pipeline_progress_ui.detail , 88 )
local lines = {
' { \\ b1} ' .. _pipeline_progress_ui.ass_escape ( title ) .. ' { \\ b0} ' ,
}
if summary ~= ' ' then
lines [ # lines + 1 ] = _pipeline_progress_ui.ass_escape ( summary )
end
if detail ~= ' ' then
lines [ # lines + 1 ] = ' { \\ fs18 \\ c&HDDDDDD&} ' .. _pipeline_progress_ui.ass_escape ( detail ) .. ' { \\ r} '
end
overlay.data = string.format (
' { \\ an9 \\ pos(%d,%d) \\ fs22 \\ bord2 \\ shad1 \\ 1c&HFFFFFF& \\ 3c&H111111& \\ 4c&H000000&}%s ' ,
width - 28 ,
34 ,
table.concat ( lines , ' \\ N ' )
)
overlay : update ( )
end
function _pipeline_progress_ui . hide ( )
_pipeline_progress_ui.cancel_hide ( )
_pipeline_progress_ui.title = ' '
_pipeline_progress_ui.summary = ' '
_pipeline_progress_ui.detail = ' '
_pipeline_progress_ui.render ( )
end
function _pipeline_progress_ui . schedule_hide ( delay_seconds )
_pipeline_progress_ui.cancel_hide ( )
local delay = tonumber ( delay_seconds or 0 ) or 0
if delay <= 0 then
_pipeline_progress_ui.hide ( )
return
end
local token = _pipeline_progress_ui.hide_token
mp.add_timeout ( delay , function ( )
if token ~= _pipeline_progress_ui.hide_token then
return
end
_pipeline_progress_ui.hide ( )
end )
end
function _pipeline_progress_ui . update ( title , summary , detail )
_pipeline_progress_ui.cancel_hide ( )
_pipeline_progress_ui.title = _pipeline_progress_ui.trim ( title )
_pipeline_progress_ui.summary = _pipeline_progress_ui.trim ( summary )
_pipeline_progress_ui.detail = _pipeline_progress_ui.trim ( detail )
_pipeline_progress_ui.render ( )
end
2025-12-17 17:42:46 -08:00
2026-03-18 01:26:55 -07:00
local function _get_lua_source_path ( )
local info = nil
pcall ( function ( )
info = debug.getinfo ( 1 , ' S ' )
end )
local source = info and info.source or ' '
if type ( source ) == ' string ' and source : sub ( 1 , 1 ) == ' @ ' then
return source : sub ( 2 )
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
return ' '
end
2025-12-27 03:13:16 -08:00
2026-03-18 01:26:55 -07:00
local function _detect_repo_root ( )
local function find_up ( start_dir , relative_path , max_levels )
local d = start_dir
local levels = max_levels or 8
for _ = 0 , levels do
if d and d ~= ' ' then
local candidate = d .. ' / ' .. relative_path
if utils.file_info ( candidate ) then
return candidate
2025-12-18 22:50:21 -08:00
end
end
2026-03-18 01:26:55 -07:00
local parent = d and d : match ( ' (.*)[/ \\ ] ' ) or nil
if not parent or parent == d or parent == ' ' then
break
end
d = parent
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
return nil
end
2025-12-18 22:50:21 -08:00
2026-03-18 01:26:55 -07:00
local bases = {
( _get_lua_source_path ( ) : match ( ' (.*)[/ \\ ] ' ) or ' ' ) ,
mp.get_script_directory ( ) or ' ' ,
utils.getcwd ( ) or ' ' ,
( ( opts and opts.cli_path ) and tostring ( opts.cli_path ) : match ( ' (.*)[/ \\ ] ' ) or ' ' ) ,
}
for _ , base in ipairs ( bases ) do
2025-12-18 22:50:21 -08:00
if base ~= ' ' then
local cli = find_up ( base , ' CLI.py ' , 8 )
if cli and cli ~= ' ' then
2026-03-18 01:26:55 -07:00
return cli : match ( ' (.*)[/ \\ ] ' ) or ' '
2025-12-18 22:50:21 -08:00
end
end
end
2026-03-18 01:26:55 -07:00
return ' '
end
local function _append_lua_log_file ( payload )
payload = tostring ( payload or ' ' )
payload = payload : gsub ( ' ^%s+ ' , ' ' ) : gsub ( ' %s+$ ' , ' ' )
if payload == ' ' then
return nil
end
local log_dir = ' '
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
log_dir = utils.join_path ( repo_root , ' Log ' )
end
if log_dir == ' ' then
log_dir = os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or utils.getcwd ( ) or ' '
end
if log_dir == ' ' then
return nil
end
local path = utils.join_path ( log_dir , ' medeia-mpv-lua.log ' )
local fh = io.open ( path , ' a ' )
if not fh and repo_root ~= ' ' then
local tmp = os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or ' '
if tmp ~= ' ' then
path = utils.join_path ( tmp , ' medeia-mpv-lua.log ' )
fh = io.open ( path , ' a ' )
end
end
if not fh then
return nil
end
fh : write ( ' [ ' .. tostring ( os.date ( ' %Y-%m-%d %H:%M:%S ' ) ) .. ' ] ' .. payload .. ' \n ' )
fh : close ( )
return path
end
local function _emit_to_mpv_log ( payload )
payload = tostring ( payload or ' ' )
if payload == ' ' then
return
end
local text = ' [medeia] ' .. payload
local lower = payload : lower ( )
if lower : find ( ' [error] ' , 1 , true )
or lower : find ( ' helper not running ' , 1 , true )
or lower : find ( ' failed ' , 1 , true )
or lower : find ( ' timeout ' , 1 , true ) then
pcall ( msg.error , text )
return
end
if lower : find ( ' [warn] ' , 1 , true ) or lower : find ( ' warning ' , 1 , true ) then
pcall ( msg.warn , text )
return
end
pcall ( msg.verbose , text )
end
-- Dedicated Lua log: write directly to logs.db database for unified logging
-- Fallback to stderr if database unavailable
local function _lua_log ( text )
local payload = ( text and tostring ( text ) or ' ' )
if payload == ' ' then
return
end
_append_lua_log_file ( payload )
_emit_to_mpv_log ( payload )
-- Attempt to find repo root for database access
local repo_root = _detect_repo_root ( )
2026-02-06 23:34:20 -08:00
-- Write to logs.db via Python subprocess (non-blocking, async)
if repo_root ~= ' ' then
local python = ( opts and opts.python_path ) and tostring ( opts.python_path ) or ' python '
local db_path = ( repo_root .. ' /logs.db ' ) : gsub ( ' \\ ' , ' / ' )
local msg = payload : gsub ( ' \\ ' , ' \\ \\ ' ) : gsub ( " ' " , " \\ ' " )
2025-12-18 22:50:21 -08:00
2026-02-06 23:34:20 -08:00
local script = string.format (
" import sqlite3; p='%s'; c=sqlite3.connect(p); c.execute( \" CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, level TEXT, module TEXT, message TEXT) \" ); c.execute( \" INSERT INTO logs (level,module,message) VALUES (?,?,?) \" , ('DEBUG','mpv','%s')); c.commit(); c.close() " ,
db_path ,
msg
)
pcall ( function ( )
2026-03-18 01:26:55 -07:00
mp.command_native_async ( { name = ' subprocess ' , args = { python , ' -c ' , script } } , function ( ) end )
2026-02-06 23:34:20 -08:00
end )
2025-12-18 22:50:21 -08:00
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 ( ) ) )
2026-02-06 23:34:20 -08:00
-- Combined log: to database (primary) + _lua_log (which also writes to db)
2026-02-04 16:59:04 -08:00
local function _log_all ( level , text )
if not text or text == ' ' then
return
end
level = tostring ( level or ' INFO ' ) : upper ( )
text = tostring ( text )
2026-02-06 23:34:20 -08:00
-- Log with level prefix via _lua_log (which writes to database)
2026-02-04 16:59:04 -08:00
_lua_log ( ' [ ' .. level .. ' ] ' .. text )
end
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
2026-03-22 22:41:56 -07:00
M._disable_input_section = function ( name , reason )
local section = tostring ( name or ' ' )
if section == ' ' then
return
end
local ok , err = pcall ( mp.commandv , ' disable-section ' , section )
if not ok then
_lua_log ( ' ui: disable-section failed name= ' .. section .. ' reason= ' .. tostring ( reason or ' unknown ' ) .. ' err= ' .. tostring ( err ) )
end
end
M._reset_uosc_input_state = function ( reason )
local why = tostring ( reason or ' unknown ' )
M._disable_input_section ( ' input_console ' , why )
M._disable_input_section ( ' input_forced_console ' , why )
if not ensure_uosc_loaded ( ) then
return false
end
pcall ( mp.commandv , ' script-message-to ' , ' uosc ' , ' close-menu ' )
2026-03-25 00:56:58 -07:00
pcall ( mp.commandv , ' script-message-to ' , ' uosc ' , ' sync-cursor ' )
M._disable_input_section ( ' input_console ' , why .. ' @immediate ' )
M._disable_input_section ( ' input_forced_console ' , why .. ' @immediate ' )
2026-03-22 22:41:56 -07:00
mp.add_timeout ( 0.05 , function ( )
if ensure_uosc_loaded ( ) then
pcall ( mp.commandv , ' script-message-to ' , ' uosc ' , ' sync-cursor ' )
end
M._disable_input_section ( ' input_console ' , why .. ' @sync ' )
M._disable_input_section ( ' input_forced_console ' , why .. ' @sync ' )
end )
2026-03-25 00:56:58 -07:00
mp.add_timeout ( 0.20 , function ( )
if ensure_uosc_loaded ( ) then
pcall ( mp.commandv , ' script-message-to ' , ' uosc ' , ' sync-cursor ' )
end
M._disable_input_section ( ' input_console ' , why .. ' @sync2 ' )
M._disable_input_section ( ' input_forced_console ' , why .. ' @sync2 ' )
end )
2026-03-22 22:41:56 -07:00
return true
end
M._open_uosc_menu = function ( menu_data , reason )
local why = tostring ( reason or ' menu ' )
if not ensure_uosc_loaded ( ) then
_lua_log ( ' menu: uosc not available; cannot open-menu reason= ' .. why )
return false
end
M._reset_uosc_input_state ( why .. ' :pre-open ' )
local payload = utils.format_json ( menu_data or { } )
local ok , err = pcall ( mp.commandv , ' script-message-to ' , ' uosc ' , ' open-menu ' , payload )
if not ok then
_lua_log ( ' menu: open-menu failed reason= ' .. why .. ' err= ' .. tostring ( err ) )
return false
end
return true
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 )
2026-02-06 23:34:20 -08:00
-- Configuration (global so _lua_log can see python_path early)
opts = {
2025-11-27 10:59:01 -08:00
python_path = " python " ,
cli_path = nil -- Will be auto-detected if nil
}
2026-02-03 17:14:11 -08:00
-- Read script options from script-opts/medeia.conf when available
pcall ( function ( )
local mpopts = require ( ' mp.options ' )
mpopts.read_options ( opts , ' medeia ' )
end )
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
2026-03-15 01:51:10 -07:00
local function _append_unique_path ( out , seen , path )
path = trim ( tostring ( path or ' ' ) )
if path == ' ' then
return
end
local key = path : gsub ( ' \\ ' , ' / ' ) : lower ( )
if seen [ key ] then
return
end
seen [ key ] = true
out [ # out + 1 ] = path
end
2026-03-18 01:26:55 -07:00
local function _path_exists ( path )
path = trim ( tostring ( path or ' ' ) )
if path == ' ' then
return false
end
return utils.file_info ( path ) ~= nil
end
2026-03-18 12:24:37 -07:00
local function _normalize_fs_path ( path )
path = trim ( tostring ( path or ' ' ) )
path = path : gsub ( ' ^"+ ' , ' ' ) : gsub ( ' "+$ ' , ' ' )
return trim ( path )
end
2026-03-18 01:26:55 -07:00
local function _build_python_candidates ( configured_python , prefer_no_console )
local candidates = { }
local seen = { }
local function add ( path )
path = trim ( tostring ( path or ' ' ) )
if path == ' ' then
return
end
local key = path : gsub ( ' \\ ' , ' / ' ) : lower ( )
if seen [ key ] then
return
end
seen [ key ] = true
candidates [ # candidates + 1 ] = path
end
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
local sep = package and package.config and package.config : sub ( 1 , 1 ) or ' / '
if sep == ' \\ ' then
if prefer_no_console then
add ( repo_root .. ' /.venv/Scripts/pythonw.exe ' )
add ( repo_root .. ' /venv/Scripts/pythonw.exe ' )
end
add ( repo_root .. ' /.venv/Scripts/python.exe ' )
add ( repo_root .. ' /venv/Scripts/python.exe ' )
else
add ( repo_root .. ' /.venv/bin/python3 ' )
add ( repo_root .. ' /.venv/bin/python ' )
add ( repo_root .. ' /venv/bin/python3 ' )
add ( repo_root .. ' /venv/bin/python ' )
end
end
if _path_exists ( configured_python ) then
if prefer_no_console then
local sep = package and package.config and package.config : sub ( 1 , 1 ) or ' / '
if sep == ' \\ ' then
local low = configured_python : lower ( )
if low : sub ( - 10 ) == ' python.exe ' then
local pythonw = configured_python : sub ( 1 , # configured_python - 10 ) .. ' pythonw.exe '
if _path_exists ( pythonw ) then
add ( pythonw )
end
end
end
end
add ( configured_python )
elseif configured_python ~= ' ' and configured_python ~= ' python ' and configured_python ~= ' python.exe ' then
add ( configured_python )
end
local sep = package and package.config and package.config : sub ( 1 , 1 ) or ' / '
if sep == ' \\ ' then
add ( ' python ' )
add ( ' py ' )
else
add ( ' python3 ' )
add ( ' python ' )
end
return candidates
end
local function _detect_format_probe_script ( )
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
local direct = utils.join_path ( repo_root , ' MPV/format_probe.py ' )
if _path_exists ( direct ) then
return direct
end
end
local candidates = { }
local seen = { }
local source_dir = _get_lua_source_path ( ) : match ( ' (.*)[/ \\ ] ' ) or ' '
local script_dir = mp.get_script_directory ( ) or ' '
local cwd = utils.getcwd ( ) or ' '
_append_unique_path ( candidates , seen , find_file_upwards ( source_dir , ' MPV/format_probe.py ' , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( script_dir , ' MPV/format_probe.py ' , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( cwd , ' MPV/format_probe.py ' , 8 ) )
for _ , candidate in ipairs ( candidates ) do
if _path_exists ( candidate ) then
return candidate
end
end
return ' '
end
local function _describe_subprocess_result ( result )
if result == nil then
return ' result=nil '
end
if type ( result ) ~= ' table ' then
return ' result= ' .. tostring ( result )
end
local parts = { }
if result.error ~= nil then
parts [ # parts + 1 ] = ' error= ' .. tostring ( result.error )
end
if result.status ~= nil then
parts [ # parts + 1 ] = ' status= ' .. tostring ( result.status )
end
if result.killed_by_us ~= nil then
parts [ # parts + 1 ] = ' killed= ' .. tostring ( result.killed_by_us )
end
local stderr = trim ( tostring ( result.stderr or ' ' ) )
if stderr ~= ' ' then
stderr = stderr : gsub ( ' [ \r \n ]+ ' , ' ' )
if # stderr > 240 then
stderr = stderr : sub ( 1 , 240 ) .. ' ... '
end
parts [ # parts + 1 ] = ' stderr= ' .. stderr
end
local stdout = trim ( tostring ( result.stdout or ' ' ) )
if stdout ~= ' ' then
stdout = stdout : gsub ( ' [ \r \n ]+ ' , ' ' )
if # stdout > 160 then
stdout = stdout : sub ( 1 , 160 ) .. ' ... '
end
parts [ # parts + 1 ] = ' stdout= ' .. stdout
end
if # parts == 0 then
return ' result={} '
end
return table.concat ( parts , ' , ' )
end
local function _run_subprocess_command ( cmd )
local ok , result = pcall ( mp.command_native , cmd )
if not ok then
return false , nil , tostring ( result )
end
if type ( result ) == ' table ' then
local err = trim ( tostring ( result.error or ' ' ) )
local status = tonumber ( result.status )
if ( err ~= ' ' and err ~= ' success ' ) or ( status ~= nil and status ~= 0 ) then
return false , result , _describe_subprocess_result ( result )
end
end
return true , result , _describe_subprocess_result ( result )
end
2026-03-15 01:51:10 -07:00
local function _build_sibling_script_candidates ( file_name )
local candidates = { }
local seen = { }
local script_dir = mp.get_script_directory ( ) or ' '
local cwd = utils.getcwd ( ) or ' '
if script_dir ~= ' ' then
_append_unique_path ( candidates , seen , script_dir .. ' / ' .. file_name )
_append_unique_path ( candidates , seen , script_dir .. ' /LUA/ ' .. file_name )
_append_unique_path ( candidates , seen , script_dir .. ' /../ ' .. file_name )
_append_unique_path ( candidates , seen , find_file_upwards ( script_dir , ' MPV/LUA/ ' .. file_name , 8 ) )
end
if cwd ~= ' ' then
_append_unique_path ( candidates , seen , find_file_upwards ( cwd , ' MPV/LUA/ ' .. file_name , 8 ) )
end
return candidates
end
local function _load_lua_chunk_from_candidates ( label , file_name )
local candidates = _build_sibling_script_candidates ( file_name )
local last_error = nil
for _ , candidate in ipairs ( candidates ) do
local ok_load , chunk_or_err , load_err = pcall ( loadfile , candidate )
if ok_load and chunk_or_err then
local ok_run , result_or_err = pcall ( chunk_or_err )
if ok_run then
_lua_log ( label .. ' : loaded from ' .. candidate )
return true , result_or_err , candidate
end
last_error = tostring ( result_or_err or ' runtime error ' )
_lua_log ( label .. ' : runtime error at ' .. candidate .. ' ( ' .. last_error .. ' ) ' )
elseif ok_load then
last_error = tostring ( load_err or ' loadfile failed ' )
_lua_log ( label .. ' : load failed at ' .. candidate .. ' ( ' .. last_error .. ' ) ' )
else
last_error = tostring ( chunk_or_err or ' loadfile failed ' )
_lua_log ( label .. ' : load failed at ' .. candidate .. ' ( ' .. last_error .. ' ) ' )
end
end
_lua_log ( label .. ' : load failed; candidates= ' .. tostring ( # candidates ) .. ' last_error= ' .. tostring ( last_error or ' not found ' ) )
return false , nil , nil , last_error
end
2026-02-06 23:34:20 -08:00
-- Forward declaration (defined later) used by helper auto-start.
local _resolve_python_exe
2026-03-18 12:24:37 -07:00
local _refresh_store_cache
local _uosc_open_list_picker
local _run_pipeline_detached
2026-03-18 20:17:28 -07:00
local _run_pipeline_background_job
2026-02-06 23:34:20 -08:00
2025-12-18 22:50:21 -08:00
local _cached_store_names = { }
local _store_cache_loaded = false
2026-03-18 01:26:55 -07:00
local _store_cache_retry_pending = false
2025-12-18 22:50:21 -08:00
2026-01-03 03:37:48 -08:00
-- Optional index into _cached_store_names (used by some older menu code paths).
-- If unset, callers should fall back to reading SELECTED_STORE_PROP.
local _selected_store_index = nil
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
2026-03-18 01:26:55 -07:00
local _current_url_for_web_actions
local _store_status_hint_for_url
local _refresh_current_store_url_status
local _skip_next_store_check_url = ' '
2026-03-21 22:56:37 -07:00
local _pick_folder_windows
2026-03-18 01:26:55 -07:00
2026-03-22 02:09:27 -07:00
function M . _load_store_choices_direct_async ( cb )
cb = cb or function ( ) end
local refresh_state = M._store_direct_refresh_state or { inflight = false , callbacks = { } }
M._store_direct_refresh_state = refresh_state
if refresh_state.inflight then
table.insert ( refresh_state.callbacks , cb )
_lua_log ( ' stores: direct config load join existing request ' )
return
end
local python = _resolve_python_exe ( false )
if not python or python == ' ' then
cb ( nil , ' no python executable available ' )
return
end
local repo_root = _detect_repo_root ( )
if not repo_root or repo_root == ' ' then
cb ( nil , ' repo root not found ' )
return
end
local function finish ( resp , err )
local callbacks = refresh_state.callbacks or { }
refresh_state.callbacks = { }
refresh_state.inflight = false
for _ , pending_cb in ipairs ( callbacks ) do
pcall ( pending_cb , resp , err )
end
end
local bootstrap = table.concat ( {
' import json, os, sys ' ,
' root = sys.argv[1] ' ,
' if root: ' ,
' os.chdir(root) ' ,
' sys.path.insert(0, root) if root not in sys.path else None ' ,
' from SYS.logger import set_thread_stream ' ,
' set_thread_stream(sys.stderr) ' ,
' from SYS.config import load_config ' ,
' from Store.registry import list_configured_backend_names ' ,
' config = load_config() ' ,
' choices = list_configured_backend_names(config) or [] ' ,
' sys.stdout.write(json.dumps({"choices": choices}, ensure_ascii=False)) ' ,
} , ' \n ' )
refresh_state.inflight = true
refresh_state.callbacks = { cb }
_lua_log ( ' stores: spawning direct config scan python= ' .. tostring ( python ) .. ' root= ' .. tostring ( repo_root ) )
mp.command_native_async (
{
name = ' subprocess ' ,
args = { python , ' -c ' , bootstrap , repo_root } ,
capture_stdout = true ,
capture_stderr = true ,
playback_only = false ,
} ,
function ( success , result , err )
if not success then
finish ( nil , tostring ( err or ' subprocess failed ' ) )
return
end
if type ( result ) ~= ' table ' then
finish ( nil , ' invalid subprocess result ' )
return
end
local status = tonumber ( result.status or 0 ) or 0
local stdout = trim ( tostring ( result.stdout or ' ' ) )
local stderr = trim ( tostring ( result.stderr or ' ' ) )
if status ~= 0 then
local detail = stderr
if detail == ' ' then
detail = tostring ( result.error or ( ' direct store scan exited with status ' .. tostring ( status ) ) )
end
finish ( nil , detail )
return
end
if stdout == ' ' then
local detail = stderr
if detail == ' ' then
detail = ' direct store scan returned no output '
end
finish ( nil , detail )
return
end
local ok , resp = pcall ( utils.parse_json , stdout )
if ok and type ( resp ) == ' string ' then
ok , resp = pcall ( utils.parse_json , resp )
end
if not ok or type ( resp ) ~= ' table ' then
local stdout_preview = stdout
if # stdout_preview > 200 then
stdout_preview = stdout_preview : sub ( 1 , 200 ) .. ' ... '
end
local stderr_preview = stderr
if # stderr_preview > 200 then
stderr_preview = stderr_preview : sub ( 1 , 200 ) .. ' ... '
end
_lua_log ( ' stores: direct config parse failed stdout= ' .. tostring ( stdout_preview ) .. ' stderr= ' .. tostring ( stderr_preview ) )
finish ( nil , ' failed to parse direct store scan output ' )
return
end
finish ( resp , nil )
end
)
end
2026-03-18 01:26:55 -07:00
local function _normalize_store_name ( store )
store = trim ( tostring ( store or ' ' ) )
store = store : gsub ( ' ^" ' , ' ' ) : gsub ( ' "$ ' , ' ' )
return trim ( store )
end
2025-12-27 03:13:16 -08:00
2026-03-21 22:56:37 -07:00
local function _is_cached_store_name ( store )
local needle = _normalize_store_name ( store )
if needle == ' ' then
return false
end
if type ( _cached_store_names ) ~= ' table ' then
return false
end
for _ , name in ipairs ( _cached_store_names ) do
if _normalize_store_name ( name ) == needle then
return true
end
end
return false
end
2025-12-27 03:13:16 -08:00
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
2026-03-26 23:00:25 -07:00
function M . _get_selected_store_state_path ( )
local dir = _get_script_opts_dir ( )
if not dir then
return nil
end
return utils.join_path ( dir , ' medeia-selected-store.json ' )
end
2026-03-22 22:41:56 -07:00
function M . _get_store_cache_path ( )
local dir = _get_script_opts_dir ( )
if not dir then
return nil
end
return utils.join_path ( dir , ' medeia-store-cache.json ' )
end
function M . _load_store_names_from_disk ( )
local path = M._get_store_cache_path ( )
if not path then
return nil
end
local fh = io.open ( path , ' r ' )
if not fh then
return nil
end
local raw = fh : read ( ' *a ' )
fh : close ( )
raw = trim ( tostring ( raw or ' ' ) )
if raw == ' ' then
return nil
end
local ok , payload = pcall ( utils.parse_json , raw )
if not ok or type ( payload ) ~= ' table ' or type ( payload.choices ) ~= ' table ' then
return nil
end
local out = { }
for _ , value in ipairs ( payload.choices ) do
local name = _normalize_store_name ( value )
if name ~= ' ' then
out [ # out + 1 ] = name
end
end
return # out > 0 and out or nil
end
function M . _save_store_names_to_disk ( names )
if type ( names ) ~= ' table ' or # names == 0 then
return false
end
local path = M._get_store_cache_path ( )
if not path then
return false
end
local fh = io.open ( path , ' w ' )
if not fh then
return false
end
fh : write ( utils.format_json ( { choices = names } ) )
fh : close ( )
return true
end
function M . _prime_store_cache_from_disk ( )
if type ( _cached_store_names ) == ' table ' and # _cached_store_names > 0 then
return true
end
local names = M._load_store_names_from_disk ( )
if type ( names ) ~= ' table ' or # names == 0 then
return false
end
_cached_store_names = names
_store_cache_loaded = true
_lua_log ( ' stores: primed ' .. tostring ( # names ) .. ' stores from disk cache ' )
return true
end
2025-12-27 03:13:16 -08:00
local function _load_selected_store_from_disk ( )
2026-03-26 23:00:25 -07:00
local state_path = M._get_selected_store_state_path ( )
if state_path then
local fh = io.open ( state_path , ' r ' )
if fh then
local raw = fh : read ( ' *a ' )
fh : close ( )
raw = trim ( tostring ( raw or ' ' ) )
if raw ~= ' ' then
local ok , payload = pcall ( utils.parse_json , raw )
if ok and type ( payload ) == ' table ' then
local value = _normalize_store_name ( payload.store )
if value ~= ' ' then
return value
end
end
end
end
end
2025-12-27 03:13:16 -08:00
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 ( )
2026-03-18 01:26:55 -07:00
v = _normalize_store_name ( v )
2025-12-27 03:13:16 -08:00
return v ~= ' ' and v or nil
end
end
end
fh : close ( )
return nil
end
local function _save_selected_store_to_disk ( store )
2026-03-26 23:00:25 -07:00
local path = M._get_selected_store_state_path ( )
2025-12-27 03:13:16 -08:00
if not path then
return false
end
local fh = io.open ( path , ' w ' )
if not fh then
return false
end
2026-03-26 23:00:25 -07:00
fh : write ( utils.format_json ( { store = _normalize_store_name ( store ) } ) )
2025-12-27 03:13:16 -08:00
fh : close ( )
return true
end
local function _get_selected_store ( )
local v = ' '
pcall ( function ( )
v = tostring ( mp.get_property ( SELECTED_STORE_PROP ) or ' ' )
end )
2026-03-18 01:26:55 -07:00
return _normalize_store_name ( v )
2025-12-27 03:13:16 -08:00
end
local function _set_selected_store ( store )
2026-03-18 01:26:55 -07:00
store = _normalize_store_name ( store )
2025-12-27 03:13:16 -08:00
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 )
2026-03-18 01:26:55 -07:00
disk = _normalize_store_name ( disk )
2025-12-27 03:13:16 -08:00
if disk ~= ' ' then
pcall ( mp.set_property , SELECTED_STORE_PROP , disk )
end
2026-03-26 23:00:25 -07:00
pcall ( function ( )
local legacy_path = _get_selected_store_conf_path ( )
if not legacy_path then
return
end
local fh = io.open ( legacy_path , ' r ' )
if not fh then
return
end
local raw = fh : read ( ' *a ' )
fh : close ( )
raw = tostring ( raw or ' ' )
if raw == ' ' or not raw : lower ( ) : find ( ' store%s*= ' ) then
return
end
local lines = { }
for line in raw : gmatch ( ' [^ \r \n ]+ ' ) do
local s = trim ( tostring ( line or ' ' ) )
local k = s : match ( ' ^([%w_%-]+)%s*= ' )
if not ( k and k : lower ( ) == ' store ' ) then
lines [ # lines + 1 ] = line
end
end
local out = table.concat ( lines , ' \n ' )
if out ~= ' ' then
out = out .. ' \n '
end
local writer = io.open ( legacy_path , ' w ' )
if not writer then
return
end
writer : write ( out )
writer : close ( )
end )
2026-03-22 22:41:56 -07:00
pcall ( M._prime_store_cache_from_disk )
2025-12-27 03:13:16 -08:00
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
2026-03-21 17:23:26 -07:00
-- Debounce helper start attempts (window in seconds).
-- Initialize below zero so the very first startup attempt is never rejected.
local _helper_start_debounce_ts = - 1000
2026-02-03 17:14:11 -08:00
local HELPER_START_DEBOUNCE = 2.0
2026-02-06 23:34:20 -08:00
-- Track ready-heartbeat freshness so stale or non-timestamp values don't mask a stopped helper
local _helper_ready_last_value = ' '
local _helper_ready_last_seen_ts = 0
local HELPER_READY_STALE_SECONDS = 10.0
2026-03-22 22:41:56 -07:00
M._lyric_helper_state = M._lyric_helper_state or { last_start_ts = - 1000 , debounce = 3.0 }
M._subtitle_autoselect_state = M._subtitle_autoselect_state or { serial = 0 , deadline = 0 }
2026-02-06 23:34:20 -08:00
2025-12-18 22:50:21 -08:00
local function _is_pipeline_helper_ready ( )
2026-03-21 19:02:30 -07:00
local helper_version = mp.get_property ( ' user-data/medeia-pipeline-helper-version ' )
if helper_version == nil or helper_version == ' ' then
helper_version = mp.get_property_native ( ' user-data/medeia-pipeline-helper-version ' )
end
helper_version = tostring ( helper_version or ' ' )
2026-03-22 22:41:56 -07:00
if helper_version ~= ' 2026-03-23.1 ' then
2026-03-21 19:02:30 -07:00
return false
end
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
2026-02-06 23:34:20 -08:00
_helper_ready_last_value = ' '
_helper_ready_last_seen_ts = 0
2025-12-18 22:50:21 -08:00
return false
end
local s = tostring ( ready )
if s == ' ' or s == ' 0 ' then
2026-02-06 23:34:20 -08:00
_helper_ready_last_value = s
_helper_ready_last_seen_ts = 0
2025-12-18 22:50:21 -08:00
return false
end
2026-02-06 23:34:20 -08:00
local now = mp.get_time ( ) or 0
if s ~= _helper_ready_last_value then
_helper_ready_last_value = s
_helper_ready_last_seen_ts = now
end
-- Prefer timestamp heartbeats from modern helpers.
2025-12-18 22:50:21 -08:00
local n = tonumber ( s )
if n and n > 1000000000 then
2026-02-06 23:34:20 -08:00
local os_now = ( os and os.time ) and os.time ( ) or nil
if os_now then
local age = os_now - n
if age < 0 then
age = 0
end
if age <= HELPER_READY_STALE_SECONDS then
return true
end
2026-03-21 17:23:26 -07:00
return false
2025-12-18 22:50:21 -08:00
end
end
2026-03-21 17:23:26 -07:00
-- Fall back only for non-timestamp values so stale helper timestamps from a
-- previous session do not look fresh right after Lua reload.
2026-02-06 23:34:20 -08:00
if _helper_ready_last_seen_ts > 0 and ( now - _helper_ready_last_seen_ts ) <= HELPER_READY_STALE_SECONDS then
return true
end
return false
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
local function _helper_ready_diagnostics ( )
local ready = mp.get_property ( PIPELINE_READY_PROP )
if ready == nil or ready == ' ' then
ready = mp.get_property_native ( PIPELINE_READY_PROP )
end
2026-03-21 19:02:30 -07:00
local helper_version = mp.get_property ( ' user-data/medeia-pipeline-helper-version ' )
if helper_version == nil or helper_version == ' ' then
helper_version = mp.get_property_native ( ' user-data/medeia-pipeline-helper-version ' )
end
2026-03-18 01:26:55 -07:00
local now = mp.get_time ( ) or 0
local age = ' n/a '
if _helper_ready_last_seen_ts > 0 then
age = string.format ( ' %.2fs ' , math.max ( 0 , now - _helper_ready_last_seen_ts ) )
end
return ' ready= ' .. tostring ( ready or ' ' )
2026-03-21 19:02:30 -07:00
.. ' helper_version= ' .. tostring ( helper_version or ' ' )
2026-03-22 22:41:56 -07:00
.. ' required_version=2026-03-23.1 '
2026-03-18 01:26:55 -07:00
.. ' last_value= ' .. tostring ( _helper_ready_last_value or ' ' )
.. ' last_seen_age= ' .. tostring ( age )
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
2026-02-02 02:32:28 -08:00
return ' \\ \\ . \\ pipe \\ mpv-medios-macina '
2025-12-17 17:42:46 -08:00
end
2026-02-02 02:32:28 -08:00
return ' /tmp/mpv-medios-macina.sock '
2025-12-17 17:42:46 -08:00
end
2025-12-18 22:50:21 -08:00
local function ensure_mpv_ipc_server ( )
2026-01-03 03:37:48 -08:00
-- `.mpv -play` (Python) controls MPV via JSON IPC. If mpv was started
2025-12-18 22:50:21 -08:00
-- 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
2026-02-03 17:14:11 -08:00
local function attempt_start_pipeline_helper_async ( callback )
-- Async version: spawn helper without blocking UI. Calls callback(success) when done.
callback = callback or function ( ) end
2026-03-21 22:56:37 -07:00
local helper_start_state = M._helper_start_state or { inflight = false , callbacks = { } }
M._helper_start_state = helper_start_state
local function finish ( success )
local callbacks = helper_start_state.callbacks or { }
helper_start_state.callbacks = { }
helper_start_state.inflight = false
for _ , cb in ipairs ( callbacks ) do
pcall ( cb , success )
end
end
2026-02-03 17:14:11 -08:00
if _is_pipeline_helper_ready ( ) then
callback ( true )
return
end
2026-03-21 22:56:37 -07:00
if helper_start_state.inflight then
table.insert ( helper_start_state.callbacks , callback )
_lua_log ( ' attempt_start_pipeline_helper_async: join existing startup ' )
return
end
2026-02-03 17:14:11 -08:00
-- Debounce: don't spawn multiple helpers in quick succession
local now = mp.get_time ( )
2026-03-21 17:23:26 -07:00
if _helper_start_debounce_ts > - 1 and ( now - _helper_start_debounce_ts ) < HELPER_START_DEBOUNCE then
2026-02-03 17:14:11 -08:00
_lua_log ( ' attempt_start_pipeline_helper_async: debounced (recent attempt) ' )
callback ( false )
return
end
_helper_start_debounce_ts = now
2026-03-21 22:56:37 -07:00
helper_start_state.inflight = true
helper_start_state.callbacks = { callback }
2026-03-21 17:23:26 -07:00
-- Clear any stale ready heartbeat from an earlier helper instance before spawning.
pcall ( mp.set_property , PIPELINE_READY_PROP , ' ' )
2026-03-21 19:02:30 -07:00
pcall ( mp.set_property , ' user-data/medeia-pipeline-helper-version ' , ' ' )
2026-03-21 17:23:26 -07:00
_helper_ready_last_value = ' '
_helper_ready_last_seen_ts = 0
2026-02-03 17:14:11 -08:00
local python = _resolve_python_exe ( true )
if not python or python == ' ' then
_lua_log ( ' attempt_start_pipeline_helper_async: no python executable available ' )
2026-03-21 22:56:37 -07:00
finish ( false )
2026-02-03 17:14:11 -08:00
return
end
2026-03-22 00:59:03 -07:00
local helper_script = ' '
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
local direct = utils.join_path ( repo_root , ' MPV/pipeline_helper.py ' )
if _path_exists ( direct ) then
helper_script = direct
end
end
if helper_script == ' ' then
local candidates = { }
local seen = { }
local source_dir = _get_lua_source_path ( ) : match ( ' (.*)[/ \\ ] ' ) or ' '
local script_dir = mp.get_script_directory ( ) or ' '
local cwd = utils.getcwd ( ) or ' '
_append_unique_path ( candidates , seen , find_file_upwards ( source_dir , ' MPV/pipeline_helper.py ' , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( script_dir , ' MPV/pipeline_helper.py ' , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( cwd , ' MPV/pipeline_helper.py ' , 8 ) )
for _ , candidate in ipairs ( candidates ) do
if _path_exists ( candidate ) then
helper_script = candidate
break
end
end
end
if helper_script == ' ' then
_lua_log ( ' attempt_start_pipeline_helper_async: pipeline_helper.py not found ' )
finish ( false )
return
end
local launch_root = repo_root
if launch_root == ' ' then
launch_root = helper_script : match ( ' (.*)[/ \\ ]MPV[/ \\ ] ' ) or ( helper_script : match ( ' (.*)[/ \\ ] ' ) or ' ' )
end
local bootstrap = table.concat ( {
' import os, runpy, sys ' ,
' script = sys.argv[1] ' ,
' root = sys.argv[2] ' ,
' if root: ' ,
' os.chdir(root) ' ,
' sys.path.insert(0, root) if root not in sys.path else None ' ,
' sys.argv = [script] + sys.argv[3:] ' ,
' runpy.run_path(script, run_name="__main__") ' ,
} , ' \n ' )
local args = { python , ' -c ' , bootstrap , helper_script , launch_root , ' --ipc ' , get_mpv_ipc_path ( ) , ' --timeout ' , ' 30 ' }
_lua_log ( ' attempt_start_pipeline_helper_async: spawning helper python= ' .. tostring ( python ) .. ' script= ' .. tostring ( helper_script ) .. ' root= ' .. tostring ( launch_root ) )
2026-02-03 17:14:11 -08:00
-- Spawn detached; don't wait for it here (async).
2026-03-18 12:24:37 -07:00
local ok , result , detail = _run_subprocess_command ( { name = ' subprocess ' , args = args , detach = true } )
2026-03-18 01:26:55 -07:00
_lua_log ( ' attempt_start_pipeline_helper_async: detached spawn result ' .. tostring ( detail or ' ' ) )
2026-02-03 17:14:11 -08:00
if not ok then
_lua_log ( ' attempt_start_pipeline_helper_async: detached spawn failed, retrying blocking ' )
2026-03-18 12:24:37 -07:00
ok , result , detail = _run_subprocess_command ( { name = ' subprocess ' , args = args } )
2026-03-18 01:26:55 -07:00
_lua_log ( ' attempt_start_pipeline_helper_async: blocking spawn result ' .. tostring ( detail or ' ' ) )
2026-02-03 17:14:11 -08:00
end
if not ok then
2026-03-18 01:26:55 -07:00
_lua_log ( ' attempt_start_pipeline_helper_async: spawn failed final= ' .. tostring ( detail or _describe_subprocess_result ( result ) ) )
2026-03-21 22:56:37 -07:00
finish ( false )
2026-02-03 17:14:11 -08:00
return
end
-- Wait for helper to become ready in background (non-blocking).
2026-03-25 00:56:58 -07:00
-- The Python helper can spend up to 12s recovering a stale singleton lock
-- before it even starts connecting to mpv IPC, so the Lua-side wait must be
-- comfortably longer than that to avoid false startup failures.
local deadline = mp.get_time ( ) + 45.0
2026-02-03 17:14:11 -08:00
local timer
timer = mp.add_periodic_timer ( 0.1 , function ( )
if _is_pipeline_helper_ready ( ) then
timer : kill ( )
_lua_log ( ' attempt_start_pipeline_helper_async: helper ready ' )
2026-03-21 22:56:37 -07:00
finish ( true )
2026-02-03 17:14:11 -08:00
return
end
if mp.get_time ( ) >= deadline then
timer : kill ( )
2026-03-25 00:56:58 -07:00
_lua_log ( ' attempt_start_pipeline_helper_async: timeout waiting for ready ' .. _helper_ready_diagnostics ( ) )
2026-03-22 00:59:03 -07:00
-- Reset debounce so the next attempt is not immediate; gives the
-- still-running Python helper time to die or acquire the lock.
_helper_start_debounce_ts = mp.get_time ( )
2026-03-21 22:56:37 -07:00
finish ( false )
2026-02-03 17:14:11 -08:00
end
end )
end
2026-01-12 17:55:04 -08:00
local function ensure_pipeline_helper_running ( )
2026-02-03 17:14:11 -08:00
-- Check if helper is already running (don't spawn from here).
-- Auto-start is handled via explicit menu action only.
return _is_pipeline_helper_ready ( )
2026-01-12 17:55:04 -08:00
end
2026-03-22 22:41:56 -07:00
function M . _resolve_repo_script ( relative_path )
relative_path = trim ( tostring ( relative_path or ' ' ) )
if relative_path == ' ' then
return ' ' , ' '
end
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
local direct = utils.join_path ( repo_root , relative_path )
if _path_exists ( direct ) then
return direct , repo_root
end
end
local candidates = { }
local seen = { }
local source_dir = _get_lua_source_path ( ) : match ( ' (.*)[/ \\ ] ' ) or ' '
local script_dir = mp.get_script_directory ( ) or ' '
local cwd = utils.getcwd ( ) or ' '
_append_unique_path ( candidates , seen , find_file_upwards ( source_dir , relative_path , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( script_dir , relative_path , 8 ) )
_append_unique_path ( candidates , seen , find_file_upwards ( cwd , relative_path , 8 ) )
for _ , candidate in ipairs ( candidates ) do
if _path_exists ( candidate ) then
local launch_root = candidate : match ( ' (.*)[/ \\ ]MPV[/ \\ ] ' ) or ( candidate : match ( ' (.*)[/ \\ ] ' ) or ' ' )
return candidate , launch_root
end
end
return ' ' , repo_root
end
function M . _attempt_start_lyric_helper_async ( reason )
reason = trim ( tostring ( reason or ' startup ' ) )
local state = M._lyric_helper_state or { last_start_ts = - 1000 , debounce = 3.0 }
M._lyric_helper_state = state
if not ensure_mpv_ipc_server ( ) then
_lua_log ( ' lyric-helper: missing mpv IPC server reason= ' .. tostring ( reason ) )
return false
end
local now = mp.get_time ( ) or 0
if ( state.last_start_ts or - 1000 ) > - 1 and ( now - ( state.last_start_ts or - 1000 ) ) < ( state.debounce or 3.0 ) then
return false
end
state.last_start_ts = now
local python = _resolve_python_exe ( true )
if not python or python == ' ' then
python = _resolve_python_exe ( false )
end
if not python or python == ' ' then
_lua_log ( ' lyric-helper: no python executable available reason= ' .. tostring ( reason ) )
return false
end
local lyric_script , launch_root = M._resolve_repo_script ( ' MPV/lyric.py ' )
if lyric_script == ' ' then
_lua_log ( ' lyric-helper: MPV/lyric.py not found reason= ' .. tostring ( reason ) )
return false
end
local bootstrap = table.concat ( {
' import os, runpy, sys ' ,
' script = sys.argv[1] ' ,
' root = sys.argv[2] ' ,
' if root: ' ,
' os.chdir(root) ' ,
' sys.path.insert(0, root) if root not in sys.path else None ' ,
' sys.argv = [script] + sys.argv[3:] ' ,
' runpy.run_path(script, run_name="__main__") ' ,
} , ' \n ' )
local args = { python , ' -c ' , bootstrap , lyric_script , launch_root , ' --ipc ' , get_mpv_ipc_path ( ) }
local lyric_log = ' '
if launch_root ~= ' ' then
lyric_log = utils.join_path ( launch_root , ' Log/medeia-mpv-lyric.log ' )
end
if lyric_log ~= ' ' then
args [ # args + 1 ] = ' --log '
args [ # args + 1 ] = lyric_log
end
local ok , result , detail = _run_subprocess_command ( { name = ' subprocess ' , args = args , detach = true } )
if not ok then
ok , result , detail = _run_subprocess_command ( { name = ' subprocess ' , args = args } )
end
if not ok then
_lua_log ( ' lyric-helper: spawn failed reason= ' .. tostring ( reason ) .. ' detail= ' .. tostring ( detail or _describe_subprocess_result ( result ) ) )
return false
end
_lua_log ( ' lyric-helper: start requested reason= ' .. tostring ( reason ) .. ' script= ' .. tostring ( lyric_script ) )
return true
end
2026-01-12 17:55:04 -08:00
local _ipc_async_busy = false
local _ipc_async_queue = { }
local function _run_helper_request_async ( req , timeout_seconds , cb )
cb = cb or function ( ) end
2026-02-06 23:34:20 -08:00
if type ( req ) ~= ' table ' then
_lua_log ( ' ipc-async: invalid request ' )
cb ( nil , ' invalid request ' )
return
end
-- Assign id and label early for logging
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-async: queuing request id= ' .. id .. ' ' .. label .. ' (busy= ' .. tostring ( _ipc_async_busy ) .. ' , queue_size= ' .. tostring ( # _ipc_async_queue ) .. ' ) ' )
2026-01-12 17:55:04 -08:00
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 )
2026-02-06 23:34:20 -08:00
local err_text = err and tostring ( err ) or ' '
2026-03-18 12:24:37 -07:00
local quiet = type ( req ) == ' table ' and req.quiet and true or false
2026-02-06 23:34:20 -08:00
local is_timeout = err_text : find ( ' timeout waiting response ' , 1 , true ) ~= nil
local retry_count = type ( req ) == ' table ' and tonumber ( req._retry or 0 ) or 0
2026-03-18 20:17:28 -07:00
local op_name = type ( req ) == ' table ' and tostring ( req.op or ' ' ) or ' '
local is_retryable = is_timeout and type ( req ) == ' table ' and retry_count < 1
and ( op_name == ' ytdlp-formats ' or op_name == ' run-background ' )
2026-02-06 23:34:20 -08:00
if is_retryable then
req._retry = retry_count + 1
req.id = nil
_ipc_async_busy = false
2026-03-18 20:17:28 -07:00
_lua_log ( ' ipc-async: timeout on ' .. tostring ( op_name ) .. ' ; restarting helper and retrying (attempt ' .. tostring ( req._retry ) .. ' ) ' )
2026-02-06 23:34:20 -08:00
pcall ( mp.set_property , PIPELINE_READY_PROP , ' ' )
attempt_start_pipeline_helper_async ( function ( success )
if success then
2026-03-18 20:17:28 -07:00
_lua_log ( ' ipc-async: helper restart succeeded; retrying ' .. tostring ( op_name ) )
2026-02-06 23:34:20 -08:00
else
_lua_log ( ' ipc-async: helper restart failed; retrying anyway ' )
end
end )
mp.add_timeout ( 0.3 , function ( )
_run_helper_request_async ( req , timeout_seconds , cb )
end )
return
end
if err then
2026-03-18 12:24:37 -07:00
if quiet then
_lua_log ( ' ipc-async: done id= ' .. tostring ( id ) .. ' unavailable ' .. tostring ( label ) )
else
_lua_log ( ' ipc-async: done id= ' .. tostring ( id ) .. ' ERROR: ' .. tostring ( err ) )
end
2026-02-06 23:34:20 -08:00
else
_lua_log ( ' ipc-async: done id= ' .. tostring ( id ) .. ' success= ' .. tostring ( resp and resp.success ) )
end
2026-01-12 17:55:04 -08:00
_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 ( )
2026-02-06 23:34:20 -08:00
local function send_request_payload ( )
_lua_log ( ' ipc-async: send request id= ' .. tostring ( id ) .. ' ' .. label )
local req_json = utils.format_json ( req )
_last_ipc_last_req_json = req_json
2026-01-12 17:55:04 -08:00
2026-02-06 23:34:20 -08:00
mp.set_property ( PIPELINE_RESP_PROP , ' ' )
mp.set_property ( PIPELINE_REQ_PROP , req_json )
2026-01-12 17:55:04 -08:00
2026-02-06 23:34:20 -08:00
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
2026-01-12 17:55:04 -08:00
2026-02-06 23:34:20 -08:00
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
2026-01-12 17:55:04 -08:00
poll_timer : kill ( )
2026-02-06 23:34:20 -08:00
_lua_log ( ' ipc-async: got response id= ' .. tostring ( id ) .. ' success= ' .. tostring ( resp.success ) )
done ( resp , nil )
2026-01-12 17:55:04 -08:00
end
2026-02-06 23:34:20 -08:00
end
end )
end
2026-01-12 17:55:04 -08:00
2026-02-06 23:34:20 -08:00
local function wait_for_helper_ready ( timeout , on_ready )
local deadline = mp.get_time ( ) + ( timeout or 3.0 )
local ready_timer
ready_timer = mp.add_periodic_timer ( 0.05 , function ( )
if _is_pipeline_helper_ready ( ) then
ready_timer : kill ( )
on_ready ( )
return
end
if mp.get_time ( ) >= deadline then
ready_timer : kill ( )
2026-03-18 01:26:55 -07:00
_lua_log ( ' ipc-async: helper wait timed out ' .. _helper_ready_diagnostics ( ) )
2026-02-06 23:34:20 -08:00
done ( nil , ' helper not ready ' )
return
end
end )
end
2026-01-12 17:55:04 -08:00
2026-02-06 23:34:20 -08:00
local function ensure_helper_and_send ( )
if _is_pipeline_helper_ready ( ) then
wait_for_helper_ready ( 3.0 , send_request_payload )
2026-01-12 17:55:04 -08:00
return
end
2026-02-06 23:34:20 -08:00
_lua_log ( ' ipc-async: helper not ready, auto-starting before request id= ' .. id )
attempt_start_pipeline_helper_async ( function ( success )
if not success then
_lua_log ( ' ipc-async: helper auto-start failed while handling request id= ' .. id )
else
_lua_log ( ' ipc-async: helper auto-start triggered by request id= ' .. id )
end
end )
local helper_deadline = mp.get_time ( ) + 6.0
local helper_timer
helper_timer = mp.add_periodic_timer ( 0.1 , function ( )
if _is_pipeline_helper_ready ( ) then
helper_timer : kill ( )
wait_for_helper_ready ( 3.0 , send_request_payload )
return
end
if mp.get_time ( ) >= helper_deadline then
helper_timer : kill ( )
2026-03-18 01:26:55 -07:00
_lua_log ( ' ipc-async: helper still not running after auto-start ' .. _helper_ready_diagnostics ( ) )
2026-02-06 23:34:20 -08:00
done ( nil , ' helper not running ' )
return
end
end )
end
ensure_helper_and_send ( )
2026-01-12 17:55:04 -08:00
end
2026-03-18 12:24:37 -07:00
local function run_pipeline_via_ipc_async ( pipeline_cmd , seeds , timeout_seconds , cb )
2026-01-12 17:55:04 -08:00
local req = { pipeline = pipeline_cmd }
if seeds then
req.seeds = seeds
end
2026-03-18 12:24:37 -07:00
_run_helper_request_async ( req , timeout_seconds , function ( resp , err )
if type ( cb ) == ' function ' then
cb ( resp , err )
end
end )
2026-01-12 17:55:04 -08:00
end
2026-02-03 17:14:11 -08:00
local function _url_can_direct_load ( url )
-- Determine if a URL is safe to load directly via mpv loadfile (vs. requiring pipeline).
-- Complex streams like MPD/DASH manifests and ytdl URLs need the full pipeline.
url = tostring ( url or ' ' ) ;
local lower = url : lower ( )
-- File paths and simple URLs are OK
if lower : match ( ' ^file:// ' ) or lower : match ( ' ^file:/// ' ) then return true end
if not lower : match ( ' ^https?:// ' ) and not lower : match ( ' ^rtmp ' ) then return true end
-- Block ytdl and other complex streams
if lower : match ( ' youtube%.com ' ) or lower : match ( ' youtu%.be ' ) then return false end
if lower : match ( ' %.mpd%b() ' ) or lower : match ( ' %.mpd$ ' ) then return false end -- DASH manifest
if lower : match ( ' manifest%.json ' ) then return false end
if lower : match ( ' twitch%.tv ' ) or lower : match ( ' youtube ' ) then return false end
if lower : match ( ' soundcloud%.com ' ) or lower : match ( ' bandcamp%.com ' ) then return false end
if lower : match ( ' spotify ' ) or lower : match ( ' tidal ' ) then return false end
if lower : match ( ' reddit%.com ' ) or lower : match ( ' tiktok%.com ' ) then return false end
if lower : match ( ' vimeo%.com ' ) or lower : match ( ' dailymotion%.com ' ) then return false end
-- Default: assume direct load is OK for plain HTTP(S) URLs
return true
end
2026-02-06 23:34:20 -08:00
local function _try_direct_loadfile ( url , force )
2026-02-03 17:14:11 -08:00
-- Attempt to load URL directly via mpv without pipeline.
-- Returns (success: bool, loaded: bool) where:
-- - success=true, loaded=true: URL loaded successfully
2026-02-06 23:34:20 -08:00
-- - success=true, loaded=false: URL not suitable for direct load (when not forced)
2026-02-03 17:14:11 -08:00
-- - success=false: loadfile command failed
2026-02-06 23:34:20 -08:00
force = force and true or false
if not force and not _url_can_direct_load ( url ) then
2026-02-03 17:14:11 -08:00
_lua_log ( ' _try_direct_loadfile: URL not suitable for direct load: ' .. url )
return true , false -- Not suitable, but not an error
end
_lua_log ( ' _try_direct_loadfile: attempting loadfile for ' .. url )
local ok_load = pcall ( mp.commandv , ' loadfile ' , url , ' replace ' )
_lua_log ( ' _try_direct_loadfile: loadfile result ok_load= ' .. tostring ( ok_load ) )
return ok_load , ok_load -- Fallback attempted
end
2025-12-18 22:50:21 -08:00
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
2026-02-06 23:34:20 -08:00
_resolve_python_exe = function ( prefer_no_console )
2026-03-18 01:26:55 -07:00
local configured = trim ( tostring ( ( opts and opts.python_path ) or ' python ' ) )
local candidates = _build_python_candidates ( configured , prefer_no_console )
2025-12-24 02:13:21 -08:00
2026-03-18 01:26:55 -07:00
for _ , candidate in ipairs ( candidates ) do
if candidate : match ( ' [/ \\ ] ' ) then
if _path_exists ( candidate ) then
return candidate
end
else
2025-12-24 02:13:21 -08:00
return candidate
end
end
2026-03-18 01:26:55 -07:00
return configured ~= ' ' and configured or ' python '
2025-12-24 02:13:21 -08:00
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
2026-03-19 13:08:15 -07:00
local function _download_url_for_current_item ( url )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return ' ' , false
end
local base , query = url : match ( ' ^([^?]+)%?(.*)$ ' )
if not base or not query or query == ' ' then
return url , false
end
local base_lower = tostring ( base or ' ' ) : lower ( )
local has_explicit_video = false
if base_lower : match ( ' youtu%.be/ ' ) then
has_explicit_video = true
elseif base_lower : match ( ' youtube%.com/watch ' ) or base_lower : match ( ' youtube%-nocookie%.com/watch ' ) then
has_explicit_video = _extract_query_param ( url , ' v ' ) ~= nil
end
if not has_explicit_video then
return url , false
end
local kept = { }
local changed = false
for pair in query : gmatch ( ' [^&]+ ' ) do
local raw_key = pair : match ( ' ^([^=]+) ' ) or pair
local key = tostring ( _percent_decode ( raw_key ) or raw_key or ' ' ) : lower ( )
local keep = true
if key == ' list ' or key == ' index ' or key == ' start_radio ' or key == ' pp ' or key == ' si ' then
keep = false
changed = true
end
if keep then
kept [ # kept + 1 ] = pair
end
end
if not changed then
return url , false
end
if # kept > 0 then
return base .. ' ? ' .. table.concat ( kept , ' & ' ) , true
end
return base , true
end
2026-03-31 23:30:57 -07:00
function M . _extract_youtube_video_id ( url )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return nil
end
local lower = url : lower ( )
local video_id = nil
if lower : match ( ' youtu%.be/ ' ) then
video_id = url : match ( ' youtu%.be/([^%?&#/]+) ' )
elseif lower : match ( ' youtube%.com/watch ' ) or lower : match ( ' youtube%-nocookie%.com/watch ' ) then
video_id = _extract_query_param ( url , ' v ' )
elseif lower : match ( ' youtube%.com/shorts/ ' ) or lower : match ( ' youtube%-nocookie%.com/shorts/ ' ) then
video_id = url : match ( ' /shorts/([^%?&#/]+) ' )
elseif lower : match ( ' youtube%.com/live/ ' ) or lower : match ( ' youtube%-nocookie%.com/live/ ' ) then
video_id = url : match ( ' /live/([^%?&#/]+) ' )
elseif lower : match ( ' youtube%.com/embed/ ' ) or lower : match ( ' youtube%-nocookie%.com/embed/ ' ) then
video_id = url : match ( ' /embed/([^%?&#/]+) ' )
end
video_id = trim ( tostring ( video_id or ' ' ) )
if video_id == ' ' or not video_id : match ( ' ^[%w_-]+$ ' ) then
return nil
end
return video_id
end
function M . _suspicious_ytdl_format_reason ( fmt , url , raw )
fmt = trim ( tostring ( fmt or ' ' ) )
url = trim ( tostring ( url or ' ' ) )
if fmt == ' ' then
return nil
end
local lower_fmt = fmt : lower ( )
if lower_fmt : match ( ' ^https?:// ' ) or lower_fmt : match ( ' ^rtmp ' ) or ( url ~= ' ' and fmt == url ) then
return ' format string is a url '
end
local youtube_id = M._extract_youtube_video_id ( url )
if youtube_id and fmt == youtube_id then
return ' format matches current youtube video id '
end
if type ( raw ) == ' table ' and youtube_id then
local raw_id = trim ( tostring ( raw.id or ' ' ) )
if raw_id ~= ' ' and raw_id == youtube_id and fmt == raw_id then
return ' format matches raw youtube video id '
end
end
return nil
end
function M . _clear_suspicious_ytdl_format_for_url ( url , reason )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return false
end
local raw = mp.get_property_native ( ' ytdl-raw-info ' )
local bad_props = { }
local bad_value = nil
local bad_reason = nil
local checks = {
{
prop = ' ytdl-format ' ,
value = mp.get_property_native ( ' ytdl-format ' ) ,
} ,
{
prop = ' file-local-options/ytdl-format ' ,
value = mp.get_property ( ' file-local-options/ytdl-format ' ) ,
} ,
{
prop = ' options/ytdl-format ' ,
value = mp.get_property ( ' options/ytdl-format ' ) ,
} ,
}
for _ , item in ipairs ( checks ) do
local candidate = trim ( tostring ( item.value or ' ' ) )
local why = M._suspicious_ytdl_format_reason ( candidate , url , raw )
if why then
if not bad_value then
bad_value = candidate
end
if not bad_reason then
bad_reason = why
end
bad_props [ # bad_props + 1 ] = tostring ( item.prop )
end
end
if # bad_props == 0 then
return false
end
pcall ( mp.set_property , ' options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' file-local-options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' ytdl-format ' , ' ' )
_lua_log (
' ytdl-format: cleared suspicious selector= ' .. tostring ( bad_value or ' ' )
.. ' props= ' .. table.concat ( bad_props , ' , ' )
.. ' reason= ' .. tostring ( bad_reason or reason or ' invalid ' )
.. ' url= ' .. tostring ( url )
)
return true
end
function M . _prepare_ytdl_format_for_web_load ( url , reason )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return false
end
if M._clear_suspicious_ytdl_format_for_url ( url , reason ) then
return true
end
local normalized_url = url : gsub ( ' #.*$ ' , ' ' )
local base , query = normalized_url : match ( ' ^([^?]+)%?(.*)$ ' )
if base then
local kept = { }
for pair in query : gmatch ( ' [^&]+ ' ) do
local raw_key = pair : match ( ' ^([^=]+) ' ) or pair
local key = tostring ( _percent_decode ( raw_key ) or raw_key or ' ' ) : lower ( )
local keep = true
if key == ' t ' or key == ' start ' or key == ' time_continue ' or key == ' timestamp ' or key == ' time ' or key == ' begin ' then
keep = false
elseif key : match ( ' ^utm_ ' ) then
keep = false
end
if keep then
kept [ # kept + 1 ] = pair
end
end
if # kept > 0 then
normalized_url = base .. ' ? ' .. table.concat ( kept , ' & ' )
else
normalized_url = base
end
end
local explicit_reload_url = normalized_url
explicit_reload_url = explicit_reload_url : gsub ( ' ^[%a][%w+%.%-]*:// ' , ' ' )
explicit_reload_url = explicit_reload_url : gsub ( ' ^www%. ' , ' ' )
explicit_reload_url = explicit_reload_url : gsub ( ' /+$ ' , ' ' )
explicit_reload_url = explicit_reload_url : lower ( )
local is_explicit_reload = (
explicit_reload_url ~= ' '
and _skip_next_store_check_url ~= ' '
and explicit_reload_url == _skip_next_store_check_url
)
local active_props = { }
local first_value = nil
local checks = {
{
prop = ' ytdl-format ' ,
value = mp.get_property_native ( ' ytdl-format ' ) ,
} ,
{
prop = ' file-local-options/ytdl-format ' ,
value = mp.get_property ( ' file-local-options/ytdl-format ' ) ,
} ,
{
prop = ' options/ytdl-format ' ,
value = mp.get_property ( ' options/ytdl-format ' ) ,
} ,
}
for _ , item in ipairs ( checks ) do
local candidate = trim ( tostring ( item.value or ' ' ) )
if candidate ~= ' ' then
if not first_value then
first_value = candidate
end
active_props [ # active_props + 1 ] = tostring ( item.prop ) .. ' = ' .. candidate
end
end
if # active_props == 0 then
return false
end
if is_explicit_reload then
_lua_log (
' ytdl-format: preserving explicit reload selector reason= ' .. tostring ( reason or ' on-load ' )
.. ' url= ' .. tostring ( url )
.. ' values= ' .. table.concat ( active_props , ' ; ' )
)
return false
end
pcall ( mp.set_property , ' options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' file-local-options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' ytdl-format ' , ' ' )
_lua_log (
' ytdl-format: cleared stale selector= ' .. tostring ( first_value or ' ' )
.. ' reason= ' .. tostring ( reason or ' on-load ' )
.. ' url= ' .. tostring ( url )
.. ' values= ' .. table.concat ( active_props , ' ; ' )
)
return true
end
2026-03-18 01:26:55 -07:00
local function _normalize_url_for_store_lookup ( url )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return ' '
end
url = url : gsub ( ' #.*$ ' , ' ' )
local base , query = url : match ( ' ^([^?]+)%?(.*)$ ' )
if base then
local kept = { }
for pair in query : gmatch ( ' [^&]+ ' ) do
local raw_key = pair : match ( ' ^([^=]+) ' ) or pair
local key = tostring ( _percent_decode ( raw_key ) or raw_key or ' ' ) : lower ( )
local keep = true
if key == ' t ' or key == ' start ' or key == ' time_continue ' or key == ' timestamp ' or key == ' time ' or key == ' begin ' then
keep = false
elseif key : match ( ' ^utm_ ' ) then
keep = false
end
if keep then
kept [ # kept + 1 ] = pair
end
end
if # kept > 0 then
url = base .. ' ? ' .. table.concat ( kept , ' & ' )
else
url = base
end
end
url = url : gsub ( ' ^[%a][%w+%.%-]*:// ' , ' ' )
url = url : gsub ( ' ^www%. ' , ' ' )
url = url : gsub ( ' /+$ ' , ' ' )
return url : lower ( )
end
local function _build_store_lookup_needles ( url )
local out = { }
local seen = { }
local function add ( value )
value = trim ( tostring ( value or ' ' ) )
if value == ' ' then
return
end
local key = value : lower ( )
if seen [ key ] then
return
end
seen [ key ] = true
out [ # out + 1 ] = value
end
local raw = trim ( tostring ( url or ' ' ) )
add ( raw )
local without_fragment = raw : gsub ( ' #.*$ ' , ' ' )
add ( without_fragment )
local normalized = _normalize_url_for_store_lookup ( raw )
add ( normalized )
local schemeless = without_fragment : gsub ( ' ^[%a][%w+%.%-]*:// ' , ' ' )
schemeless = schemeless : gsub ( ' /+$ ' , ' ' )
add ( schemeless )
add ( schemeless : gsub ( ' ^www%. ' , ' ' ) )
return out
end
local function _check_store_for_existing_url ( store , url , cb )
cb = cb or function ( ) end
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
cb ( nil , ' missing url ' )
return
end
local needles = _build_store_lookup_needles ( url )
local idx = 1
local function run_next ( last_err )
if idx > # needles then
cb ( nil , last_err )
return
end
local needle = tostring ( needles [ idx ] )
idx = idx + 1
local query = ' url: ' .. needle
_lua_log ( ' store-check: probing global query= ' .. tostring ( query ) )
2026-03-18 20:17:28 -07:00
_run_helper_request_async ( { op = ' url-exists ' , data = { url = url , needles = { needle } } , quiet = true } , 5.0 , function ( resp , err )
2026-03-18 01:26:55 -07:00
if resp and resp.success then
2026-03-18 12:24:37 -07:00
local data = resp.data
if type ( data ) ~= ' table ' or # data == 0 then
2026-03-18 01:26:55 -07:00
run_next ( nil )
return
end
2026-03-18 12:24:37 -07:00
cb ( data , nil , needle )
2026-03-18 01:26:55 -07:00
return
end
local details = trim ( tostring ( err or ' ' ) )
if details == ' ' and type ( resp ) == ' table ' then
if resp.error and tostring ( resp.error ) ~= ' ' then
details = trim ( tostring ( resp.error ) )
elseif resp.stderr and tostring ( resp.stderr ) ~= ' ' then
details = trim ( tostring ( resp.stderr ) )
end
end
run_next ( details ~= ' ' and details or nil )
end )
end
run_next ( nil )
end
2025-12-18 22:50:21 -08:00
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
2026-03-18 12:24:37 -07:00
local _pending_screenshot = nil
local function _normalize_tag_list ( value )
local tags = { }
local seen = { }
local function add_tag ( text )
text = trim ( tostring ( text or ' ' ) )
if text == ' ' then
return
end
local key = text : lower ( )
if seen [ key ] then
return
end
seen [ key ] = true
tags [ # tags + 1 ] = text
end
if type ( value ) == ' table ' then
for _ , item in ipairs ( value ) do
add_tag ( item )
end
return tags
end
local text = tostring ( value or ' ' )
for token in text : gmatch ( ' [^,; \r \n ]+ ' ) do
add_tag ( token )
end
return tags
end
2026-03-19 13:08:15 -07:00
local function _write_repl_queue_file_local ( command_text , source_text , metadata )
command_text = trim ( tostring ( command_text or ' ' ) )
if command_text == ' ' then
return nil , ' empty pipeline command '
end
local repo_root = _detect_repo_root ( )
if repo_root == ' ' then
return nil , ' repo root not found '
end
local log_dir = utils.join_path ( repo_root , ' Log ' )
if not _path_exists ( log_dir ) then
return nil , ' Log directory not found '
end
local stamp = tostring ( math.floor ( mp.get_time ( ) * 1000 ) )
local token = tostring ( math.random ( 100000 , 999999 ) )
local path = utils.join_path ( log_dir , ' medeia-repl-queue- ' .. stamp .. ' - ' .. token .. ' .json ' )
local payload = {
id = stamp .. ' - ' .. token ,
command = command_text ,
source = trim ( tostring ( source_text or ' external ' ) ) ,
created_at = os.time ( ) ,
}
if type ( metadata ) == ' table ' and next ( metadata ) ~= nil then
payload.metadata = metadata
end
local encoded = utils.format_json ( payload )
if type ( encoded ) ~= ' string ' or encoded == ' ' then
return nil , ' failed to encode queue payload '
end
local fh = io.open ( path , ' w ' )
if not fh then
return nil , ' failed to open queue file '
end
fh : write ( encoded )
fh : close ( )
return path , nil
end
local function _queue_pipeline_in_repl ( pipeline_cmd , queued_message , failure_prefix , queue_label , metadata )
2026-03-18 20:17:28 -07:00
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
mp.osd_message ( ( failure_prefix or ' REPL queue failed ' ) .. ' : empty pipeline command ' , 5 )
return false
end
2026-03-19 13:08:15 -07:00
local queue_metadata = { kind = ' mpv-download ' }
if type ( metadata ) == ' table ' then
for key , value in pairs ( metadata ) do
queue_metadata [ key ] = value
end
end
2026-03-21 14:22:48 -07:00
local ipc_path = trim ( tostring ( get_mpv_ipc_path ( ) or ' ' ) )
if ipc_path ~= ' ' then
if type ( queue_metadata.mpv_notify ) == ' table ' and trim ( tostring ( queue_metadata.mpv_notify . ipc_path or ' ' ) ) == ' ' then
queue_metadata.mpv_notify . ipc_path = ipc_path
end
if type ( queue_metadata.mpv_callback ) ~= ' table ' then
queue_metadata.mpv_callback = {
ipc_path = ipc_path ,
script = mp.get_script_name ( ) ,
message = ' medeia-pipeline-event ' ,
}
end
end
2026-03-18 20:17:28 -07:00
_lua_log ( queue_label .. ' : queueing repl cmd= ' .. pipeline_cmd )
2026-03-19 13:08:15 -07:00
do
local queue_path , queue_err = _write_repl_queue_file_local (
pipeline_cmd ,
queue_label ,
queue_metadata
)
if queue_path then
_lua_log ( queue_label .. ' : queued repl command locally path= ' .. tostring ( queue_path ) )
mp.osd_message ( tostring ( queued_message or ' Queued in REPL ' ) , 3 )
return true
end
_lua_log ( queue_label .. ' : local queue write failed err= ' .. tostring ( queue_err or ' unknown ' ) .. ' ; falling back to helper ' )
end
2026-03-18 20:17:28 -07:00
ensure_mpv_ipc_server ( )
if not ensure_pipeline_helper_running ( ) then
mp.osd_message ( ( failure_prefix or ' REPL queue failed ' ) .. ' : helper not running ' , 5 )
return false
end
_run_helper_request_async (
{
op = ' queue-repl-command ' ,
data = {
command = pipeline_cmd ,
source = queue_label ,
2026-03-19 13:08:15 -07:00
metadata = queue_metadata ,
2026-03-18 20:17:28 -07:00
} ,
} ,
4.0 ,
function ( resp , err )
if resp and resp.success then
local queue_path = trim ( tostring ( resp.path or ' ' ) )
_lua_log ( queue_label .. ' : queued repl command path= ' .. tostring ( queue_path ) )
mp.osd_message ( tostring ( queued_message or ' Queued in REPL ' ) , 3 )
return
end
2026-03-19 13:08:15 -07:00
local err_text = tostring ( err or ' ' )
if err_text : find ( ' timeout waiting response ' , 1 , true ) ~= nil then
_lua_log ( queue_label .. ' : queue ack timeout; assuming repl command queued ' )
mp.osd_message ( tostring ( queued_message or ' Queued in REPL ' ) , 3 )
return
end
2026-03-18 20:17:28 -07:00
local detail = tostring ( err or ( resp and resp.error ) or ' unknown ' )
_lua_log ( queue_label .. ' : queue failed err= ' .. detail )
mp.osd_message ( ( failure_prefix or ' REPL queue failed ' ) .. ' : ' .. detail , 5 )
end
)
return true
end
2026-03-21 14:22:48 -07:00
mp.register_script_message ( ' medeia-pipeline-event ' , function ( json )
local ok , payload = pcall ( utils.parse_json , json )
if not ok or type ( payload ) ~= ' table ' then
_lua_log ( ' pipeline-event: invalid payload= ' .. tostring ( json or ' ' ) )
return
end
local encoded = utils.format_json ( payload )
if type ( encoded ) == ' string ' and encoded ~= ' ' then
pcall ( mp.set_property , ' user-data/medeia-last-pipeline-event ' , encoded )
end
2026-03-21 15:12:52 -07:00
local phase = _pipeline_progress_ui.trim ( payload.phase )
local event_name = _pipeline_progress_ui.trim ( payload.event )
local kind = _pipeline_progress_ui.trim ( payload.kind )
local title = _pipeline_progress_ui.kind_title ( kind )
local summary = ' '
local detail = ' '
if phase == ' started ' then
local command_text = _pipeline_progress_ui.trim ( payload.command_text )
summary = command_text ~= ' ' and ( ' Started: ' .. command_text ) or ( ' Started: ' .. kind )
detail = ' Queued job started '
elseif phase == ' progress ' then
if event_name == ' pipe-percent ' then
local label = _pipeline_progress_ui.trim ( payload.pipe_label ~= ' ' and payload.pipe_label or kind ~= ' ' and kind or ' pipeline ' )
local percent = tonumber ( payload.percent or 0 ) or 0
summary = ( ' %s %d%% ' ) : format ( label , math.floor ( percent + 0.5 ) )
detail = ' Processing '
elseif event_name == ' status ' then
summary = _pipeline_progress_ui.trim ( payload.text )
detail = _pipeline_progress_ui.trim ( payload.pipe_label ~= ' ' and payload.pipe_label or kind )
elseif event_name == ' transfer ' then
local label = _pipeline_progress_ui.trim ( payload.label ~= ' ' and payload.label or ' transfer ' )
local percent = tonumber ( payload.percent or 0 )
if percent then
summary = ( ' %s %d%% ' ) : format ( label , math.floor ( percent + 0.5 ) )
else
summary = label
end
local completed = tonumber ( payload.completed or 0 )
local total = tonumber ( payload.total or 0 )
if completed and total and total > 0 then
detail = ( ' %d / %d ' ) : format ( math.floor ( completed + 0.5 ) , math.floor ( total + 0.5 ) )
end
elseif event_name == ' pipe-begin ' then
local label = _pipeline_progress_ui.trim ( payload.pipe_label ~= ' ' and payload.pipe_label or kind ~= ' ' and kind or ' pipeline ' )
summary = ' Running: ' .. label
local total_items = tonumber ( payload.total_items or 0 )
if total_items and total_items > 0 then
detail = ( ' Items: %d ' ) : format ( math.floor ( total_items + 0.5 ) )
end
elseif event_name == ' pipe-emit ' then
local label = _pipeline_progress_ui.trim ( payload.pipe_label ~= ' ' and payload.pipe_label or kind ~= ' ' and kind or ' pipeline ' )
local completed = tonumber ( payload.completed or 0 ) or 0
local total = tonumber ( payload.total or 0 ) or 0
summary = total > 0 and ( ' %s %d/%d ' ) : format ( label , completed , total ) or label
detail = _pipeline_progress_ui.trim ( payload.item_label )
end
end
if phase == ' completed ' then
pcall ( mp.set_property , ' user-data/medeia-pipeline-progress ' , ' ' )
pcall ( mp.set_property , ' user-data/medeia-pipeline-progress-summary ' , ' ' )
if payload.success == false then
summary = title .. ' failed '
detail = _pipeline_progress_ui.trim ( payload.error )
if detail == ' ' then
detail = ' Unknown error '
end
else
summary = title .. ' complete '
detail = _pipeline_progress_ui.trim ( payload.command_text )
end
_pipeline_progress_ui.update ( title , summary , detail )
_pipeline_progress_ui.schedule_hide ( 2.5 )
else
if type ( encoded ) == ' string ' and encoded ~= ' ' then
pcall ( mp.set_property , ' user-data/medeia-pipeline-progress ' , encoded )
end
if summary ~= ' ' then
pcall ( mp.set_property , ' user-data/medeia-pipeline-progress-summary ' , summary )
end
_pipeline_progress_ui.update ( title , summary , detail )
end
2026-03-21 14:22:48 -07:00
_lua_log (
' pipeline-event: phase= ' .. tostring ( payload.phase or ' ' )
2026-03-21 15:12:52 -07:00
.. ' event= ' .. tostring ( payload.event or ' ' )
2026-03-21 14:22:48 -07:00
.. ' success= ' .. tostring ( payload.success )
.. ' kind= ' .. tostring ( payload.kind or ' ' )
.. ' error= ' .. tostring ( payload.error or ' ' )
)
end )
2026-03-18 12:24:37 -07:00
local function _start_screenshot_store_save ( store , out_path , tags )
store = _normalize_store_name ( store )
out_path = _normalize_fs_path ( out_path )
if store == ' ' or out_path == ' ' then
mp.osd_message ( ' Screenshot upload failed: invalid store or path ' , 5 )
return false
end
2026-03-21 22:56:37 -07:00
local is_named_store = _is_cached_store_name ( store )
2026-03-18 12:24:37 -07:00
local tag_list = _normalize_tag_list ( tags )
2026-03-21 15:15:10 -07:00
local screenshot_url = trim ( tostring ( ( _current_url_for_web_actions and _current_url_for_web_actions ( ) ) or mp.get_property ( CURRENT_WEB_URL_PROP ) or ' ' ) )
if screenshot_url == ' ' or not screenshot_url : match ( ' ^https?:// ' ) then
screenshot_url = ' '
end
2026-03-18 12:24:37 -07:00
local cmd = ' add-file -store ' .. quote_pipeline_arg ( store )
2026-03-18 20:17:28 -07:00
.. ' -path ' .. quote_pipeline_arg ( out_path )
2026-03-21 22:56:37 -07:00
if screenshot_url ~= ' ' then
cmd = cmd .. ' -url ' .. quote_pipeline_arg ( screenshot_url )
end
if is_named_store then
_set_selected_store ( store )
end
2026-03-18 20:17:28 -07:00
local tag_suffix = ( # tag_list > 0 ) and ( ' | tags: ' .. tostring ( # tag_list ) ) or ' '
2026-03-18 12:24:37 -07:00
if # tag_list > 0 then
2026-03-18 20:17:28 -07:00
local tag_string = table.concat ( tag_list , ' , ' )
cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg ( tag_string )
2026-03-18 12:24:37 -07:00
end
2026-03-21 22:56:37 -07:00
local queue_target = is_named_store and ( ' store ' .. store ) or ' folder '
local success_text = is_named_store and ( ' Screenshot saved to store: ' .. store .. tag_suffix ) or ( ' Screenshot saved to folder ' .. tag_suffix )
local failure_text = is_named_store and ' Screenshot upload failed ' or ' Screenshot save failed '
2026-03-18 12:24:37 -07:00
2026-03-20 23:27:35 -07:00
_lua_log ( ' screenshot-save: queueing repl pipeline cmd= ' .. cmd )
2026-03-18 20:17:28 -07:00
2026-03-20 23:27:35 -07:00
return _queue_pipeline_in_repl (
cmd ,
2026-03-21 22:56:37 -07:00
' Queued in REPL: screenshot -> ' .. queue_target .. tag_suffix ,
2026-03-20 23:27:35 -07:00
' Screenshot queue failed ' ,
' screenshot-save ' ,
{
kind = ' mpv-screenshot ' ,
mpv_notify = {
2026-03-21 22:56:37 -07:00
success_text = success_text ,
failure_text = failure_text ,
2026-03-20 23:27:35 -07:00
duration_ms = 3500 ,
} ,
}
)
2026-03-18 12:24:37 -07:00
end
local function _commit_pending_screenshot ( tags )
if type ( _pending_screenshot ) ~= ' table ' or not _pending_screenshot.path or not _pending_screenshot.store then
return
end
local store = tostring ( _pending_screenshot.store or ' ' )
local out_path = tostring ( _pending_screenshot.path or ' ' )
_pending_screenshot = nil
_start_screenshot_store_save ( store , out_path , tags )
end
local function _apply_screenshot_tag_query ( query )
2026-03-18 20:17:28 -07:00
pcall ( function ( )
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , SCREENSHOT_TAG_MENU_TYPE )
end )
2026-03-18 12:24:37 -07:00
_commit_pending_screenshot ( _normalize_tag_list ( query ) )
end
local function _open_screenshot_tag_prompt ( store , out_path )
store = _normalize_store_name ( store )
out_path = _normalize_fs_path ( out_path )
if store == ' ' or out_path == ' ' then
return
end
_pending_screenshot = { store = store , path = out_path }
if not ensure_uosc_loaded ( ) then
_commit_pending_screenshot ( nil )
return
end
local menu_data = {
type = SCREENSHOT_TAG_MENU_TYPE ,
title = ' Screenshot tags ' ,
search_style = ' palette ' ,
search_debounce = ' submit ' ,
on_search = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-image-screenshot-tags-search ' } ,
footnote = ' Optional comma-separated tags. Press Enter to save, or choose Save without tags. ' ,
items = {
{
title = ' Save without tags ' ,
hint = ' Skip optional tags ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-image-screenshot-tags-event ' , utils.format_json ( { query = ' ' } ) } ,
} ,
} ,
}
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , utils.format_json ( menu_data ) )
end
local function _open_store_picker_for_pending_screenshot ( )
if type ( _pending_screenshot ) ~= ' table ' or not _pending_screenshot.path then
return
end
local function build_items ( )
local selected = _get_selected_store ( )
local items = { }
2026-03-22 22:41:56 -07:00
items [ # items + 1 ] = {
title = ' Pick folder… ' ,
hint = ' Save screenshot to a local folder ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-image-screenshot-pick-path ' , ' {} ' } ,
}
2026-03-21 22:56:37 -07:00
2026-03-18 12:24:37 -07:00
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
items [ # items + 1 ] = {
title = name ,
hint = ( selected ~= ' ' and name == selected ) and ' Current store ' or ' ' ,
active = ( selected ~= ' ' and name == selected ) and true or false ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-image-screenshot-pick-store ' , utils.format_json ( { store = name } ) } ,
}
end
end
2026-03-21 22:56:37 -07:00
elseif # items == 0 then
2026-03-18 12:24:37 -07:00
items [ # items + 1 ] = {
title = ' No stores found ' ,
hint = ' Configure stores in config.conf ' ,
selectable = false ,
}
end
return items
end
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , ' Save screenshot ' , build_items ( ) )
mp.add_timeout ( 0.05 , function ( )
if type ( _pending_screenshot ) ~= ' table ' or not _pending_screenshot.path then
return
end
_refresh_store_cache ( 1.5 , function ( success , changed )
if success and changed then
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , ' Save screenshot ' , build_items ( ) )
end
end )
end )
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 '
2026-03-18 12:24:37 -07:00
local temp_dir = _normalize_fs_path ( mp.get_property ( ' user-data/medeia-config-temp ' ) )
if temp_dir == ' ' then
temp_dir = _normalize_fs_path ( os.getenv ( ' TEMP ' ) or os.getenv ( ' TMP ' ) or ' /tmp ' )
end
2025-12-27 06:05:07 -08:00
local out_path = utils.join_path ( temp_dir , filename )
2026-03-18 12:24:37 -07:00
out_path = _normalize_fs_path ( out_path )
2025-12-27 06:05:07 -08:00
2026-01-12 17:55:04 -08:00
local function do_screenshot ( mode )
mode = mode or ' video '
local ok , err = pcall ( function ( )
return mp.commandv ( ' screenshot-to-file ' , out_path , mode )
end )
return ok , err
end
-- Try 'video' first (no OSD). If that fails (e.g. audio mode without video/art),
-- try 'window' as fallback.
local ok = do_screenshot ( ' video ' )
2025-12-27 06:05:07 -08:00
if not ok then
2026-01-12 17:55:04 -08:00
_lua_log ( ' screenshot: video-mode failed; trying window-mode ' )
ok = do_screenshot ( ' window ' )
end
if not ok then
_lua_log ( ' screenshot: BOTH video and window modes FAILED ' )
mp.osd_message ( ' Screenshot failed (no frames) ' , 2 )
2025-12-27 06:05:07 -08:00
return
end
_ensure_selected_store_loaded ( )
2026-03-18 12:24:37 -07:00
local function dispatch_screenshot_save ( )
local store_count = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
local selected_store = _normalize_store_name ( _get_selected_store ( ) )
2026-03-21 22:56:37 -07:00
if not _is_cached_store_name ( selected_store ) then
selected_store = ' '
end
2026-03-18 12:24:37 -07:00
if store_count > 1 then
_pending_screenshot = { path = out_path }
_open_store_picker_for_pending_screenshot ( )
return
end
if selected_store == ' ' and store_count == 1 then
selected_store = _normalize_store_name ( _cached_store_names [ 1 ] )
end
if selected_store == ' ' then
2026-03-21 17:23:26 -07:00
_pending_screenshot = { path = out_path }
_open_store_picker_for_pending_screenshot ( )
2026-03-18 12:24:37 -07:00
return
end
_open_screenshot_tag_prompt ( selected_store , out_path )
2025-12-27 06:05:07 -08:00
end
2026-03-18 12:24:37 -07:00
if not _store_cache_loaded then
_refresh_store_cache ( 1.5 , function ( )
dispatch_screenshot_save ( )
end )
2025-12-27 06:05:07 -08:00
else
2026-03-18 12:24:37 -07:00
dispatch_screenshot_save ( )
2025-12-27 06:05:07 -08:00
end
2025-12-23 16:36:39 -08:00
end
mp.register_script_message ( ' medeia-image-screenshot ' , function ( )
_capture_screenshot ( )
end )
2026-03-18 12:24:37 -07:00
mp.register_script_message ( ' medeia-image-screenshot-pick-store ' , function ( json )
if type ( _pending_screenshot ) ~= ' table ' or not _pending_screenshot.path then
return
end
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
local store = _normalize_store_name ( ev.store )
if store == ' ' then
return
end
local out_path = tostring ( _pending_screenshot.path or ' ' )
_open_screenshot_tag_prompt ( store , out_path )
end )
2026-03-21 22:56:37 -07:00
mp.register_script_message ( ' medeia-image-screenshot-pick-path ' , function ( )
if type ( _pending_screenshot ) ~= ' table ' or not _pending_screenshot.path then
return
end
2026-03-22 22:41:56 -07:00
M._pick_folder_async ( function ( folder , err )
if err and err ~= ' ' then
mp.osd_message ( ' Folder picker failed: ' .. tostring ( err ) , 4 )
return
end
if not folder or folder == ' ' then
return
end
2026-03-21 22:56:37 -07:00
2026-03-22 22:41:56 -07:00
local out_path = tostring ( _pending_screenshot.path or ' ' )
_open_screenshot_tag_prompt ( folder , out_path )
end )
2026-03-21 22:56:37 -07:00
end )
2026-03-18 12:24:37 -07:00
mp.register_script_message ( ' medeia-image-screenshot-tags-search ' , function ( query )
_apply_screenshot_tag_query ( query )
end )
mp.register_script_message ( ' medeia-image-screenshot-tags-event ' , function ( json )
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
return
end
_apply_screenshot_tag_query ( ev.query )
end )
2025-12-23 16:36:39 -08:00
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 )
2026-03-15 01:51:10 -07:00
local function _cancel_sleep_timer ( show_message )
if _sleep_timer ~= nil then
pcall ( function ( )
_sleep_timer : kill ( )
end )
_sleep_timer = nil
end
if show_message then
mp.osd_message ( ' Sleep timer cancelled ' , 1.5 )
end
end
local function _parse_sleep_minutes ( text )
local s = trim ( tostring ( text or ' ' ) ) : lower ( )
if s == ' ' then
return nil
end
if s == ' off ' or s == ' cancel ' or s == ' stop ' or s == ' 0 ' then
return 0
end
local hours = s : match ( ' ^([%d%.]+)%s*h$ ' )
if hours then
local value = tonumber ( hours )
if value and value > 0 then
return value * 60
end
return nil
end
local mins = s : match ( ' ^([%d%.]+)%s*m$ ' )
if mins then
local value = tonumber ( mins )
if value and value >= 0 then
return value
end
return nil
end
local value = tonumber ( s )
if value and value >= 0 then
return value
end
return nil
end
local function _open_sleep_timer_prompt ( )
local items = {
{
title = ' 15 minutes ' ,
hint = ' Quick preset ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-sleep-timer-event ' , utils.format_json ( { type = ' search ' , query = ' 15 ' } ) } ,
} ,
{
title = ' 30 minutes ' ,
hint = ' Quick preset ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-sleep-timer-event ' , utils.format_json ( { type = ' search ' , query = ' 30 ' } ) } ,
} ,
{
title = ' 60 minutes ' ,
hint = ' Quick preset ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-sleep-timer-event ' , utils.format_json ( { type = ' search ' , query = ' 60 ' } ) } ,
} ,
{
title = ' Cancel timer ' ,
hint = ' Also accepts off / 0 / cancel ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-sleep-timer-event ' , utils.format_json ( { type = ' search ' , query = ' 0 ' } ) } ,
} ,
}
local menu_data = {
type = SLEEP_PROMPT_MENU_TYPE ,
title = ' Sleep Timer ' ,
search_style = ' palette ' ,
search_debounce = ' submit ' ,
on_search = { ' script-message-to ' , mp.get_script_name ( ) , ' medeia-sleep-timer-search ' } ,
footnote = ' Enter minutes (30), or use 1h / 1.5h. Enter 0 to cancel. ' ,
items = items ,
}
if ensure_uosc_loaded ( ) then
mp.commandv ( ' script-message-to ' , ' uosc ' , ' open-menu ' , utils.format_json ( menu_data ) )
else
mp.osd_message ( ' Sleep timer unavailable (uosc not loaded) ' , 2.0 )
end
end
local function _apply_sleep_timer_query ( query )
local minutes = _parse_sleep_minutes ( query )
if minutes == nil then
mp.osd_message ( ' Sleep timer: enter minutes, 1h, or 0 to cancel ' , 2.0 )
return
end
if minutes <= 0 then
_cancel_sleep_timer ( true )
pcall ( function ( )
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , SLEEP_PROMPT_MENU_TYPE )
end )
return
end
_cancel_sleep_timer ( false )
local seconds = math.max ( 1 , math.floor ( minutes * 60 ) )
_sleep_timer = mp.add_timeout ( seconds , function ( )
_sleep_timer = nil
mp.osd_message ( ' Sleep timer: closing mpv ' , 1.5 )
mp.commandv ( ' quit ' )
end )
mp.osd_message ( string.format ( ' Sleep timer set: %d min ' , math.floor ( minutes + 0.5 ) ) , 1.5 )
_lua_log ( ' sleep: timer set minutes= ' .. tostring ( minutes ) .. ' seconds= ' .. tostring ( seconds ) )
pcall ( function ( )
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , SLEEP_PROMPT_MENU_TYPE )
end )
end
local function _handle_sleep_timer_event ( json )
local ok , ev = pcall ( utils.parse_json , json )
if not ok or type ( ev ) ~= ' table ' then
_lua_log ( ' sleep: invalid event payload= ' .. tostring ( json ) )
return
end
if ev.type ~= ' search ' then
return
end
_apply_sleep_timer_query ( ev.query )
end
mp.register_script_message ( ' medeia-sleep-timer ' , function ( )
_open_sleep_timer_prompt ( )
end )
mp.register_script_message ( ' medeia-sleep-timer-event ' , function ( json )
_handle_sleep_timer_event ( json )
end )
mp.register_script_message ( ' medeia-sleep-timer-search ' , function ( query )
_apply_sleep_timer_query ( query )
end )
2025-12-23 16:36:39 -08:00
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 } )
_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
2026-03-21 15:12:52 -07:00
_disable_image_section ( )
2025-12-23 16:36:39 -08:00
return
end
ImageControl.enabled = false
_set_image_property ( false )
2026-03-21 15:12:52 -07:00
_disable_image_section ( )
2025-12-23 16:36:39 -08:00
_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
2026-03-22 22:41:56 -07:00
function M . _pick_folder_python_async ( cb )
cb = cb or function ( ) end
local python = _resolve_python_exe ( false )
if not python or python == ' ' then
cb ( nil , ' no python executable available ' )
return
end
local bootstrap = table.concat ( {
' import sys ' ,
' try: ' ,
' import tkinter as tk ' ,
' from tkinter import filedialog ' ,
' root = tk.Tk() ' ,
' root.withdraw() ' ,
' try: ' ,
' root.wm_attributes("-topmost", 1) ' ,
' except Exception: ' ,
' pass ' ,
' root.update() ' ,
' path = filedialog.askdirectory(title="Select download folder", mustexist=False) ' ,
' root.destroy() ' ,
' if path: ' ,
' sys.stdout.write(path) ' ,
' except Exception as exc: ' ,
' sys.stderr.write(f"{type(exc).__name__}: {exc}") ' ,
' raise SystemExit(2) ' ,
} , ' \n ' )
_lua_log ( ' folder-picker: spawning async dialog backend=python-tk ' )
mp.command_native_async (
{
name = ' subprocess ' ,
args = { python , ' -c ' , bootstrap } ,
capture_stdout = true ,
capture_stderr = true ,
playback_only = false ,
} ,
function ( success , result , err )
if not success then
cb ( nil , tostring ( err or ' subprocess failed ' ) )
return
end
if type ( result ) ~= ' table ' then
cb ( nil , ' invalid subprocess result ' )
return
end
local status = tonumber ( result.status or 0 ) or 0
local stdout = trim ( tostring ( result.stdout or ' ' ) )
local stderr = trim ( tostring ( result.stderr or ' ' ) )
if status ~= 0 then
local detail = stderr ~= ' ' and stderr or tostring ( result.error or ( ' folder picker exited with status ' .. tostring ( status ) ) )
cb ( nil , detail )
return
end
if stdout == ' ' then
cb ( nil , nil )
return
end
cb ( stdout , nil )
end
)
end
function M . _pick_folder_async ( cb )
cb = cb or function ( ) end
local jit_mod = rawget ( _G , ' jit ' )
local platform_name = tostring ( ( type ( jit_mod ) == ' table ' and jit_mod.os ) or ' ' ) : lower ( )
local function handle_result ( label , success , result , err , fallback_cb )
if not success then
local detail = tostring ( err or ' subprocess failed ' )
_lua_log ( ' folder-picker: subprocess failed backend= ' .. tostring ( label ) .. ' err= ' .. tostring ( detail ) )
if fallback_cb then
fallback_cb ( detail )
return
end
cb ( nil , detail )
return
end
if type ( result ) ~= ' table ' then
_lua_log ( ' folder-picker: invalid subprocess result backend= ' .. tostring ( label ) )
if fallback_cb then
fallback_cb ( ' invalid subprocess result ' )
return
end
cb ( nil , ' invalid subprocess result ' )
return
end
local status = tonumber ( result.status or 0 ) or 0
local stdout = trim ( tostring ( result.stdout or ' ' ) )
local stderr = trim ( tostring ( result.stderr or ' ' ) )
if status ~= 0 then
local detail = stderr
if detail == ' ' then
detail = tostring ( result.error or ( ' folder picker exited with status ' .. tostring ( status ) ) )
end
_lua_log ( ' folder-picker: failed backend= ' .. tostring ( label ) .. ' detail= ' .. tostring ( detail ) )
if fallback_cb then
fallback_cb ( detail )
return
end
cb ( nil , detail )
return
end
if stdout == ' ' then
_lua_log ( ' folder-picker: cancelled backend= ' .. tostring ( label ) )
cb ( nil , nil )
return
end
_lua_log ( ' folder-picker: selected path= ' .. tostring ( stdout ) .. ' backend= ' .. tostring ( label ) )
cb ( stdout , nil )
end
local function spawn_picker ( label , args , fallback_cb )
_lua_log ( ' folder-picker: spawning async dialog backend= ' .. tostring ( label ) )
mp.command_native_async (
{
name = ' subprocess ' ,
args = args ,
capture_stdout = true ,
capture_stderr = true ,
playback_only = false ,
} ,
function ( success , result , err )
handle_result ( label , success , result , err , fallback_cb )
end
)
end
if _is_windows ( ) then
local ps = [ [
2026-03-22 02:09:27 -07:00
Add - Type - AssemblyName System.Windows . Forms
Add - Type - AssemblyName System.Drawing
[ System.Windows . Forms.Application ] :: EnableVisualStyles ( )
$ owner = New - Object System.Windows . Forms.Form
$ owner.Text = ' medeia-folder-owner '
$ owner.StartPosition = [ System.Windows . Forms.FormStartPosition ] :: Manual
$ owner.Location = New - Object System.Drawing . Point ( - 32000 , - 32000 )
$ owner.Size = New - Object System.Drawing . Size ( 1 , 1 )
$ owner.ShowInTaskbar = $ false
$ owner.TopMost = $ true
$ owner.Opacity = 0
$ owner.FormBorderStyle = [ System.Windows . Forms.FormBorderStyle ] :: FixedToolWindow
$ owner.Add_Shown ( { $ owner.Activate ( ) } )
$ null = $ owner.Show ( )
$ d = New - Object System.Windows . Forms.FolderBrowserDialog
$ d.Description = ' Select download folder '
$ d.ShowNewFolderButton = $ true
try {
if ( $ d.ShowDialog ( $ owner ) - eq [ System.Windows . Forms.DialogResult ] :: OK ) {
$ d.SelectedPath
}
} finally {
$ d.Dispose ( )
$ owner.Close ( )
$ owner.Dispose ( )
}
] ]
2026-03-22 22:41:56 -07:00
spawn_picker ( ' windows-winforms ' , { ' powershell ' , ' -NoProfile ' , ' -WindowStyle ' , ' Hidden ' , ' -STA ' , ' -ExecutionPolicy ' , ' Bypass ' , ' -Command ' , ps } , function ( detail )
_lua_log ( ' folder-picker: windows native picker failed; trying python-tk detail= ' .. tostring ( detail or ' ' ) )
M._pick_folder_python_async ( cb )
end )
return
2025-12-18 22:50:21 -08:00
end
2026-03-22 22:41:56 -07:00
if platform_name == ' osx ' or platform_name == ' macos ' then
spawn_picker ( ' macos-osascript ' , { ' osascript ' , ' -e ' , ' POSIX path of (choose folder with prompt "Select download folder") ' } , function ( detail )
_lua_log ( ' folder-picker: macos native picker failed; trying python-tk detail= ' .. tostring ( detail or ' ' ) )
M._pick_folder_python_async ( cb )
end )
return
end
spawn_picker ( ' linux-native ' , {
' sh ' ,
' -lc ' ,
' if command -v zenity >/dev/null 2>&1; then zenity --file-selection --directory --title="Select download folder"; ' ..
' elif command -v kdialog >/dev/null 2>&1; then kdialog --getexistingdirectory "$HOME" --title "Select download folder"; ' ..
' elif command -v yad >/dev/null 2>&1; then yad --file-selection --directory --title="Select download folder"; ' ..
' else exit 127; fi '
} , function ( detail )
_lua_log ( ' folder-picker: linux native picker failed; trying python-tk detail= ' .. tostring ( detail or ' ' ) )
M._pick_folder_python_async ( cb )
end )
2025-12-18 22:50:21 -08:00
end
2026-03-22 22:41:56 -07:00
M._pick_folder_windows_async = M._pick_folder_async
2026-01-07 05:09:59 -08:00
local function _store_names_key ( names )
if type ( names ) ~= ' table ' or # names == 0 then
return ' '
end
local normalized = { }
for _ , name in ipairs ( names ) do
normalized [ # normalized + 1 ] = trim ( tostring ( name or ' ' ) )
end
return table.concat ( normalized , ' \0 ' )
end
local function _run_pipeline_request_async ( pipeline_cmd , seeds , timeout_seconds , cb )
cb = cb or function ( ) end
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
cb ( nil , ' empty pipeline command ' )
return
end
2025-12-18 22:50:21 -08:00
ensure_mpv_ipc_server ( )
2026-01-07 05:09:59 -08:00
local req = { pipeline = pipeline_cmd }
if seeds then
req.seeds = seeds
end
_run_helper_request_async ( req , timeout_seconds or 30 , cb )
end
2026-03-18 12:24:37 -07:00
_refresh_store_cache = function ( timeout_seconds , on_complete )
2026-01-07 05:09:59 -08:00
ensure_mpv_ipc_server ( )
local prev_count = ( type ( _cached_store_names ) == ' table ' ) and # _cached_store_names or 0
local prev_key = _store_names_key ( _cached_store_names )
2026-03-21 17:23:26 -07:00
local had_previous = prev_count > 0
2025-12-27 03:13:16 -08:00
2026-03-22 02:09:27 -07:00
local function apply_store_choices ( resp , source )
if not resp or type ( resp ) ~= ' table ' or type ( resp.choices ) ~= ' table ' then
_lua_log ( ' stores: ' .. tostring ( source ) .. ' result missing choices table; resp_type= ' .. tostring ( type ( resp ) ) )
return false
end
2026-01-07 05:09:59 -08:00
2026-03-22 02:09:27 -07:00
local out = { }
for _ , v in ipairs ( resp.choices ) do
local name = trim ( tostring ( v or ' ' ) )
if name ~= ' ' then
out [ # out + 1 ] = name
2026-01-07 05:09:59 -08:00
end
2026-03-22 02:09:27 -07:00
end
if # out == 0 and had_previous then
_lua_log ( ' stores: ignoring empty ' .. tostring ( source ) .. ' payload; keeping previous store list ' )
if type ( on_complete ) == ' function ' then
on_complete ( true , false )
2026-01-07 05:09:59 -08:00
end
2026-03-22 02:09:27 -07:00
return true
end
_cached_store_names = out
_store_cache_loaded = ( # out > 0 ) or _store_cache_loaded
2026-03-22 22:41:56 -07:00
pcall ( M._save_store_names_to_disk , out )
2026-03-22 02:09:27 -07:00
local payload = utils.format_json ( { choices = out } )
if type ( payload ) == ' string ' and payload ~= ' ' then
pcall ( mp.set_property , ' user-data/medeia-store-choices-cached ' , payload )
end
local preview = ' '
if # out > 0 then
preview = table.concat ( out , ' , ' )
end
_lua_log ( ' stores: loaded ' .. tostring ( # out ) .. ' stores from ' .. tostring ( source ) .. ' : ' .. tostring ( preview ) )
if type ( on_complete ) == ' function ' then
on_complete ( true , _store_names_key ( out ) ~= prev_key )
end
return true
end
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 ) )
2026-01-07 05:09:59 -08:00
2026-03-22 02:09:27 -07:00
if cached_json and cached_json ~= ' ' then
2025-12-27 03:13:16 -08:00
local ok , cached_resp = pcall ( utils.parse_json , cached_json )
_lua_log ( ' stores: cache_parse ok= ' .. tostring ( ok ) .. ' resp_type= ' .. tostring ( type ( cached_resp ) ) )
if ok then
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
2026-01-07 05:09:59 -08:00
if ok then
2026-03-22 02:09:27 -07:00
if apply_store_choices ( cached_resp , ' cache ' ) then
2026-01-07 05:09:59 -08:00
return true
2025-12-27 03:13:16 -08:00
end
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
2026-03-22 02:09:27 -07:00
if not _store_cache_retry_pending then
_store_cache_retry_pending = true
M._load_store_choices_direct_async ( function ( resp , err )
_store_cache_retry_pending = false
if apply_store_choices ( resp , ' direct config ' ) then
return
2026-01-07 05:09:59 -08:00
end
2026-03-22 02:09:27 -07:00
_lua_log ( ' stores: direct config load failed error= ' .. tostring ( err or ' unknown ' ) )
2026-03-22 22:41:56 -07:00
if had_previous then
_lua_log ( ' stores: keeping previous store list after direct config failure count= ' .. tostring ( prev_count ) )
if type ( on_complete ) == ' function ' then
on_complete ( true , false )
end
elseif type ( on_complete ) == ' function ' then
on_complete ( false , false )
end
2026-03-22 02:09:27 -07:00
end )
else
_lua_log ( ' stores: direct config refresh already pending ' )
end
2026-01-07 05:09:59 -08:00
return false
2025-12-18 22:50:21 -08:00
end
2026-03-18 12:24:37 -07:00
_uosc_open_list_picker = function ( menu_type , title , items )
2025-12-18 22:50:21 -08:00
local menu_data = {
type = menu_type ,
title = title ,
items = items or { } ,
}
2026-03-22 22:41:56 -07:00
if not M._open_uosc_menu ( menu_data , ' list-picker: ' .. tostring ( menu_type or title or ' menu ' ) ) then
2025-12-18 22:50:21 -08:00
_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 ( )
2026-03-18 01:26:55 -07:00
local current_url = trim ( tostring ( _current_url_for_web_actions ( ) or _current_target ( ) or ' ' ) )
2025-12-27 03:13:16 -08:00
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 }
2026-03-18 01:26:55 -07:00
local hint = _store_status_hint_for_url ( name , current_url , nil )
if ( not hint or hint == ' ' ) and selected ~= ' ' and name == selected then
hint = ' Current store '
end
2025-12-27 03:13:16 -08:00
items [ # items + 1 ] = {
title = name ,
2026-03-18 01:26:55 -07:00
hint = hint ,
2025-12-27 03:13:16 -08:00
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 )
2026-01-07 05:09:59 -08:00
_refresh_store_cache ( 1.2 , function ( success , changed )
if success and changed then
_lua_log ( ' stores: reopening menu (store list changed) ' )
_uosc_open_list_picker ( STORE_PICKER_MENU_TYPE , ' Store ' , build_items ( ) )
end
end )
2025-12-27 03:13:16 -08:00
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 )
2026-03-18 01:26:55 -07:00
_refresh_current_store_url_status ( ' store-select ' )
2025-12-27 03:13:16 -08:00
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 ,
} , FileState )
end
2026-03-16 04:43:46 -07:00
function FileState : set_url ( url )
local normalized = trim ( tostring ( url or ' ' ) )
if normalized == ' ' then
self.url = nil
self.formats = nil
self.formats_table = nil
return
end
if self.url ~= normalized then
self.url = normalized
self.formats = nil
self.formats_table = nil
end
end
2025-12-19 02:29:42 -08:00
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 )
2026-03-16 04:43:46 -07:00
self : set_url ( url )
2025-12-19 02:29:42 -08:00
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 = { }
2026-03-18 01:26:55 -07:00
local _formats_prefetch_retries = { }
local _format_cache_poll_generation = 0
local _last_raw_format_summary = ' '
2026-03-16 04:43:46 -07:00
local _get_cached_formats_table
2026-03-18 01:26:55 -07:00
local _debug_dump_formatted_formats
local _show_format_list_osd
2025-12-19 02:29:42 -08:00
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
2026-02-06 23:34:20 -08:00
local _ytdlp_domains_cached = nil
local function _is_ytdlp_url ( u )
if not u or type ( u ) ~= ' string ' then
return false
end
local low = trim ( u : lower ( ) )
if not low : match ( ' ^https?:// ' ) then
return false
end
-- Fast exclusions for things we know are not meant for yt-dlp format switching
if low : find ( ' /get_files/file ' , 1 , true ) then return false end
if low : find ( ' tidal.com/manifest ' , 1 , true ) then return false end
if low : find ( ' alldebrid.com/f/ ' , 1 , true ) then return false end
-- Try to use the cached domain list from the pipeline helper
local domains_str = mp.get_property ( ' user-data/medeia-ytdlp-domains-cached ' ) or ' '
if domains_str ~= ' ' then
if not _ytdlp_domains_cached then
_ytdlp_domains_cached = { }
for d in domains_str : gmatch ( ' %S+ ' ) do
_ytdlp_domains_cached [ d ] = true
end
end
local host = low : match ( ' ^https?://([^/]+) ' )
if host then
-- Remove port if present
host = host : match ( ' ^([^:]+) ' )
-- Check direct match or parent domain matches
local parts = { }
for p in host : gmatch ( ' [^%.]+ ' ) do
table.insert ( parts , p )
end
-- Check from full domain down to top-level (e.g. m.youtube.com, youtube.com)
for i = 1 , # parts - 1 do
local candidate = table.concat ( parts , ' . ' , i )
if _ytdlp_domains_cached [ candidate ] then
return true
end
end
end
end
-- Fallback/Hardcoded: Probable video/audio sites for which Change Format is actually useful
local patterns = {
' youtube%.com ' , ' youtu%.be ' , ' vimeo%.com ' , ' twitch%.tv ' ,
' soundcloud%.com ' , ' bandcamp%.com ' , ' bilibili%.com ' ,
' dailymotion%.com ' , ' pixiv%.net ' , ' twitter%.com ' ,
' x%.com ' , ' instagram%.com ' , ' tiktok%.com ' , ' reddit%.com ' ,
' facebook%.com ' , ' fb%.watch '
}
for _ , p in ipairs ( patterns ) do
if low : match ( p ) then
return true
end
end
-- If we have formats already cached for this URL, it's definitely supported
if _get_cached_formats_table ( u ) then
return true
end
return false
end
2026-03-16 04:43:46 -07:00
local function _set_current_web_url ( url )
local normalized = trim ( tostring ( url or ' ' ) )
if normalized ~= ' ' and _is_http_url ( normalized ) then
pcall ( mp.set_property , CURRENT_WEB_URL_PROP , normalized )
if type ( M.file ) == ' table ' and M.file . set_url then
M.file : set_url ( normalized )
end
return normalized
end
pcall ( mp.set_property , CURRENT_WEB_URL_PROP , ' ' )
if type ( M.file ) == ' table ' and M.file . set_url then
M.file : set_url ( nil )
end
return nil
end
local function _get_current_web_url ( )
local current = trim ( tostring ( mp.get_property ( CURRENT_WEB_URL_PROP ) or ' ' ) )
if current ~= ' ' and _is_http_url ( current ) then
return current
end
return nil
end
2026-03-18 01:26:55 -07:00
_current_url_for_web_actions = function ( )
2026-03-16 04:43:46 -07:00
local current = _get_current_web_url ( )
if current and current ~= ' ' then
return current
end
local target = _current_target ( )
if not target or target == ' ' then
return nil
end
return tostring ( target )
end
2026-03-22 22:41:56 -07:00
function M . _build_web_ytdl_raw_options ( )
local raw = trim ( tostring ( mp.get_property ( ' ytdl-raw-options ' ) or ' ' ) )
if raw == ' ' then
raw = trim ( tostring ( mp.get_property ( ' options/ytdl-raw-options ' ) or ' ' ) )
end
local lower = raw : lower ( )
local extra = { }
if not lower : find ( ' write%-subs= ' , 1 ) then
extra [ # extra + 1 ] = ' write-subs= '
end
if not lower : find ( ' write%-auto%-subs= ' , 1 ) then
extra [ # extra + 1 ] = ' write-auto-subs= '
end
if not lower : find ( ' sub%-langs= ' , 1 ) then
2026-03-26 23:00:25 -07:00
extra [ # extra + 1 ] = ' sub-langs=[en.*,en,-live_chat] '
2026-03-22 22:41:56 -07:00
end
if # extra == 0 then
return raw ~= ' ' and raw or nil
end
if raw ~= ' ' then
return raw .. ' , ' .. table.concat ( extra , ' , ' )
end
return table.concat ( extra , ' , ' )
end
function M . _apply_web_subtitle_load_defaults ( reason )
local target = trim ( tostring ( mp.get_property ( ' path ' ) or mp.get_property ( ' stream-open-filename ' ) or ' ' ) )
if target == ' ' or not _is_http_url ( target ) then
return false
end
2026-03-31 23:30:57 -07:00
M._prepare_ytdl_format_for_web_load ( target , reason or ' on-load ' )
2026-03-22 22:41:56 -07:00
local raw = M._build_web_ytdl_raw_options ( )
if raw and raw ~= ' ' then
pcall ( mp.set_property , ' file-local-options/ytdl-raw-options ' , raw )
end
pcall ( mp.set_property , ' file-local-options/sub-visibility ' , ' yes ' )
pcall ( mp.set_property , ' file-local-options/sid ' , ' auto ' )
pcall ( mp.set_property , ' file-local-options/track-auto-selection ' , ' yes ' )
_lua_log ( ' web-subtitles: prepared load defaults reason= ' .. tostring ( reason or ' on-load ' ) .. ' target= ' .. tostring ( target ) )
return true
end
function M . _find_subtitle_track_candidate ( )
local tracks = mp.get_property_native ( ' track-list ' )
if type ( tracks ) ~= ' table ' then
return nil , nil , false
end
2026-03-26 23:00:25 -07:00
local function subtitle_track_blob ( track )
local parts = { }
local fields = { ' lang ' , ' title ' , ' name ' , ' external-filename ' }
for _ , key in ipairs ( fields ) do
local value = ' '
if type ( track ) == ' table ' then
value = trim ( tostring ( track [ key ] or ' ' ) ) : lower ( )
end
if value ~= ' ' then
parts [ # parts + 1 ] = value
end
end
return table.concat ( parts , ' ' )
end
local function subtitle_track_is_english ( track , blob )
local lang = ' '
if type ( track ) == ' table ' then
lang = trim ( tostring ( track.lang or ' ' ) ) : lower ( )
end
if lang == ' en ' or lang == ' eng ' or lang : match ( ' ^en[-_] ' ) or lang : match ( ' ^eng[-_] ' ) then
return true
end
local text = blob or subtitle_track_blob ( track )
if text : match ( ' %f[%a]english%f[%A] ' ) then
return true
end
return false
end
local function subtitle_track_is_autogenerated ( track , blob )
local text = blob or subtitle_track_blob ( track )
local markers = {
' auto-generated ' ,
' auto generated ' ,
' autogenerated ' ,
' automatic captions ' ,
' automatic subtitles ' ,
' generated automatically ' ,
' asr ' ,
}
for _ , marker in ipairs ( markers ) do
if text : find ( marker , 1 , true ) then
return true
end
end
return false
end
local best_id = nil
local best_source = nil
local best_selected = false
local best_score = nil
2026-03-22 22:41:56 -07:00
for _ , track in ipairs ( tracks ) do
if type ( track ) == ' table ' and tostring ( track.type or ' ' ) == ' sub ' and not track.albumart then
local id = tonumber ( track.id )
if id then
2026-03-26 23:00:25 -07:00
local blob = subtitle_track_blob ( track )
local selected = track.selected and true or false
local source = ' fallback '
local score = 100
if blob : find ( ' medeia-sub ' , 1 , true ) then
source = ' medeia-note '
score = 1000
else
local english = subtitle_track_is_english ( track , blob )
local autogenerated = subtitle_track_is_autogenerated ( track , blob )
if english and not autogenerated then
source = ' english-manual '
score = 800
elseif english and autogenerated then
source = ' english-auto '
score = 700
elseif selected then
source = ' selected '
score = 300
elseif track.default then
source = ' default '
score = 200
else
source = ' first '
score = 100
end
end
if selected then
score = score + 50
end
if track.default then
score = score + 25
2026-03-22 22:41:56 -07:00
end
2026-03-26 23:00:25 -07:00
if type ( track.external ) == ' boolean ' and track.external then
score = score + 10
2026-03-22 22:41:56 -07:00
end
2026-03-26 23:00:25 -07:00
if best_score == nil or score > best_score then
best_score = score
best_id = id
best_source = source
best_selected = selected
2026-03-22 22:41:56 -07:00
end
end
end
end
2026-03-26 23:00:25 -07:00
if best_id ~= nil then
return best_id , best_source , best_selected
2026-03-22 22:41:56 -07:00
end
return nil , nil , false
end
function M . _ensure_current_subtitles_visible ( reason )
local state = M._subtitle_autoselect_state or { serial = 0 , deadline = 0 }
M._subtitle_autoselect_state = state
if ( mp.get_time ( ) or 0 ) > ( state.deadline or 0 ) then
return false
end
local current = _get_current_web_url ( )
local path = trim ( tostring ( mp.get_property ( ' path ' ) or ' ' ) )
if ( not current or current == ' ' ) and ( path == ' ' or not _is_http_url ( path ) ) then
return false
end
local track_id , source , already_selected = M._find_subtitle_track_candidate ( )
if not track_id then
return false
end
pcall ( mp.set_property , ' sub-visibility ' , ' yes ' )
if already_selected then
return true
end
local ok = pcall ( mp.set_property_native , ' sid ' , track_id )
if ok then
_lua_log ( ' web-subtitles: selected subtitle track id= ' .. tostring ( track_id ) .. ' source= ' .. tostring ( source or ' unknown ' ) .. ' reason= ' .. tostring ( reason or ' auto ' ) )
end
return ok and true or false
end
function M . _schedule_web_subtitle_activation ( reason )
local state = M._subtitle_autoselect_state or { serial = 0 , deadline = 0 }
M._subtitle_autoselect_state = state
state.serial = ( state.serial or 0 ) + 1
local serial = state.serial
state.deadline = ( mp.get_time ( ) or 0 ) + 12.0
local delays = { 0.15 , 0.75 , 2.0 , 5.0 }
for _ , delay in ipairs ( delays ) do
mp.add_timeout ( delay , function ( )
local current_state = M._subtitle_autoselect_state or { }
if serial ~= current_state.serial then
return
end
M._ensure_current_subtitles_visible ( ( reason or ' file-loaded ' ) .. ' @ ' .. tostring ( delay ) )
end )
end
end
2026-03-16 04:43:46 -07:00
local function _sync_current_web_url_from_playback ( )
local target = _current_target ( )
local target_str = trim ( tostring ( target or ' ' ) )
if target_str ~= ' ' and _extract_store_hash ( target_str ) then
_set_current_web_url ( nil )
return
end
if target_str ~= ' ' and _is_http_url ( target_str ) and _is_ytdlp_url ( target_str ) then
_set_current_web_url ( target_str )
return
end
local current = _get_current_web_url ( )
if current and current ~= ' ' then
local raw = mp.get_property_native ( ' ytdl-raw-info ' )
if type ( raw ) == ' table ' then
if type ( M.file ) == ' table ' and M.file . set_url then
M.file : set_url ( current )
end
return
end
end
if target_str == ' ' or not _is_http_url ( target_str ) then
_set_current_web_url ( nil )
end
end
2026-03-25 00:56:58 -07:00
mp.add_hook ( ' on_load ' , 50 , function ( )
2026-03-22 22:41:56 -07:00
local ok , err = pcall ( M._apply_web_subtitle_load_defaults , ' on_load ' )
if not ok then
_lua_log ( ' web-subtitles: on_load setup failed err= ' .. tostring ( err ) )
end
end )
2026-03-18 01:26:55 -07:00
local _current_store_url_status = {
generation = 0 ,
store = ' ' ,
url = ' ' ,
status = ' idle ' ,
err = ' ' ,
match_count = 0 ,
needle = ' ' ,
}
local function _set_current_store_url_status ( store , url , status , err , match_count , needle )
_current_store_url_status.store = trim ( tostring ( store or ' ' ) )
_current_store_url_status.url = trim ( tostring ( url or ' ' ) )
_current_store_url_status.status = trim ( tostring ( status or ' idle ' ) )
_current_store_url_status.err = trim ( tostring ( err or ' ' ) )
_current_store_url_status.match_count = tonumber ( match_count or 0 ) or 0
_current_store_url_status.needle = trim ( tostring ( needle or ' ' ) )
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
local function _begin_current_store_url_status ( store , url , status , err , match_count , needle )
_current_store_url_status.generation = ( _current_store_url_status.generation or 0 ) + 1
_set_current_store_url_status ( store , url , status , err , match_count , needle )
return _current_store_url_status.generation
end
local function _current_store_url_status_matches ( store , url )
local current_url = _normalize_url_for_store_lookup ( _current_store_url_status.url )
local target_url = _normalize_url_for_store_lookup ( url )
return current_url ~= ' ' and current_url == target_url
end
_store_status_hint_for_url = function ( store , url , fallback )
store = trim ( tostring ( store or ' ' ) )
url = trim ( tostring ( url or ' ' ) )
if store == ' ' or url == ' ' or not _current_store_url_status_matches ( store , url ) then
return fallback
end
local status = tostring ( _current_store_url_status.status or ' ' )
if status == ' pending ' or status == ' checking ' then
return ' Checking current URL '
end
if status == ' found ' then
return ' Current URL already exists '
end
if status == ' missing ' then
return fallback or ' Current URL not found '
end
if status == ' error ' then
if fallback and fallback ~= ' ' then
return fallback .. ' | lookup unavailable '
end
return ' Lookup unavailable '
end
return fallback
end
_refresh_current_store_url_status = function ( reason )
reason = trim ( tostring ( reason or ' ' ) )
_ensure_selected_store_loaded ( )
local store = trim ( tostring ( _get_selected_store ( ) or ' ' ) )
local target = _current_url_for_web_actions ( ) or _current_target ( )
local url = trim ( tostring ( target or ' ' ) )
local normalized_url = _normalize_url_for_store_lookup ( url )
if url == ' ' or not _is_http_url ( url ) or _extract_store_hash ( url ) then
_begin_current_store_url_status ( store , url , ' idle ' )
return
end
if normalized_url ~= ' ' and _skip_next_store_check_url ~= ' ' and normalized_url == _skip_next_store_check_url then
_lua_log ( ' store-check: skipped reason= ' .. tostring ( reason ) .. ' url= ' .. tostring ( url ) .. ' (format reload) ' )
_skip_next_store_check_url = ' '
return
end
local generation = _begin_current_store_url_status ( store , url , ' pending ' )
if not _is_pipeline_helper_ready ( ) then
2026-03-22 02:09:27 -07:00
_lua_log ( ' store-check: helper not ready; skipping lookup reason= ' .. tostring ( reason ) .. ' store= ' .. tostring ( store ) )
_set_current_store_url_status ( store , url , ' idle ' )
2026-03-18 01:26:55 -07:00
return
end
_set_current_store_url_status ( store , url , ' checking ' )
_lua_log ( ' store-check: starting reason= ' .. tostring ( reason ) .. ' store= ' .. tostring ( store ) .. ' url= ' .. tostring ( url ) )
_check_store_for_existing_url ( store , url , function ( matches , err , needle )
if _current_store_url_status.generation ~= generation then
return
end
local active_store = trim ( tostring ( _get_selected_store ( ) or ' ' ) )
local active_target = _current_url_for_web_actions ( ) or _current_target ( )
local active_url = trim ( tostring ( active_target or ' ' ) )
if active_store ~= store or active_url ~= url then
_lua_log ( ' store-check: stale response discarded store= ' .. tostring ( store ) .. ' url= ' .. tostring ( url ) )
return
end
if type ( matches ) == ' table ' and # matches > 0 then
_set_current_store_url_status ( store , url , ' found ' , nil , # matches , needle )
_lua_log ( ' store-check: found matches= ' .. tostring ( # matches ) .. ' needle= ' .. tostring ( needle or ' ' ) )
return
end
local err_text = trim ( tostring ( err or ' ' ) )
if err_text ~= ' ' then
_set_current_store_url_status ( store , url , ' error ' , err_text , 0 , needle )
2026-03-18 12:24:37 -07:00
_lua_log ( ' store-check: lookup unavailable ' )
2026-03-18 01:26:55 -07:00
return
end
_set_current_store_url_status ( store , url , ' missing ' , nil , 0 , needle )
_lua_log ( ' store-check: missing url= ' .. tostring ( url ) )
end )
end
local function _cache_formats_for_url ( url , tbl )
if type ( url ) ~= ' string ' or url == ' ' then
return
end
if type ( tbl ) ~= ' table ' then
return
end
_formats_cache [ url ] = { table = tbl , ts = mp.get_time ( ) }
if type ( M.file ) == ' table ' and M.file . set_formats then
M.file : set_formats ( url , tbl )
else
M.file . url = url
M.file . formats = tbl
M.file . formats_table = tbl
end
end
_get_cached_formats_table = function ( url )
if type ( url ) ~= ' string ' or url == ' ' then
return nil
2025-12-18 22:50:21 -08:00
end
local hit = _formats_cache [ url ]
if type ( hit ) == ' table ' and type ( hit.table ) == ' table ' then
return hit.table
end
return nil
end
2026-03-18 01:26:55 -07:00
local function _format_bytes_compact ( size_bytes )
local value = tonumber ( size_bytes )
if not value or value <= 0 then
return ' '
end
local units = { ' B ' , ' KB ' , ' MB ' , ' GB ' , ' TB ' }
local unit_index = 1
while value >= 1024 and unit_index < # units do
value = value / 1024
unit_index = unit_index + 1
end
if unit_index == 1 then
return tostring ( math.floor ( value + 0.5 ) ) .. units [ unit_index ]
end
return string.format ( ' %.1f%s ' , value , units [ unit_index ] )
end
local function _is_browseable_raw_format ( fmt )
if type ( fmt ) ~= ' table ' then
return false
end
local format_id = trim ( tostring ( fmt.format_id or ' ' ) )
if format_id == ' ' then
return false
end
local ext = trim ( tostring ( fmt.ext or ' ' ) ) : lower ( )
if ext == ' mhtml ' or ext == ' json ' then
return false
end
local note = trim ( tostring ( fmt.format_note or fmt.format or ' ' ) ) : lower ( )
if note : find ( ' storyboard ' , 1 , true ) then
return false
end
if format_id : lower ( ) : match ( ' ^sb ' ) then
return false
end
local vcodec = tostring ( fmt.vcodec or ' none ' )
local acodec = tostring ( fmt.acodec or ' none ' )
return not ( vcodec == ' none ' and acodec == ' none ' )
end
local function _build_formats_table_from_raw_info ( url , raw )
if raw == nil then
raw = mp.get_property_native ( ' ytdl-raw-info ' )
end
if type ( raw ) ~= ' table ' then
return nil , ' missing ytdl-raw-info '
end
local formats = raw.formats
if type ( formats ) ~= ' table ' or # formats == 0 then
return nil , ' ytdl-raw-info has no formats '
end
local rows = { }
local browseable_count = 0
for _ , fmt in ipairs ( formats ) do
if _is_browseable_raw_format ( fmt ) then
browseable_count = browseable_count + 1
local format_id = trim ( tostring ( fmt.format_id or ' ' ) )
local resolution = trim ( tostring ( fmt.resolution or ' ' ) )
if resolution == ' ' then
local width = tonumber ( fmt.width )
local height = tonumber ( fmt.height )
if width and height then
resolution = tostring ( math.floor ( width ) ) .. ' x ' .. tostring ( math.floor ( height ) )
elseif height then
resolution = tostring ( math.floor ( height ) ) .. ' p '
end
end
local ext = trim ( tostring ( fmt.ext or ' ' ) )
local size = _format_bytes_compact ( fmt.filesize or fmt.filesize_approx )
local vcodec = tostring ( fmt.vcodec or ' none ' )
local acodec = tostring ( fmt.acodec or ' none ' )
local selection_id = format_id
if vcodec ~= ' none ' and acodec == ' none ' then
selection_id = format_id .. ' +ba '
end
rows [ # rows + 1 ] = {
columns = {
{ name = ' ID ' , value = format_id } ,
{ name = ' Resolution ' , value = resolution } ,
{ name = ' Ext ' , value = ext } ,
{ name = ' Size ' , value = size } ,
} ,
selection_args = { ' -format ' , selection_id } ,
}
end
end
if browseable_count == 0 then
return { title = ' Formats ' , rows = { } } , nil
end
return { title = ' Formats ' , rows = rows } , nil
end
local function _summarize_formats_table ( tbl , limit )
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' then
return ' rows=0 '
end
limit = tonumber ( limit or 6 ) or 6
local parts = { }
for i = 1 , math.min ( # tbl.rows , limit ) do
local row = tbl.rows [ i ] or { }
local cols = row.columns or { }
local id_val = ' '
local res_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
end
parts [ # parts + 1 ] = id_val ~= ' ' and ( id_val .. ( res_val ~= ' ' and ( ' @ ' .. res_val ) or ' ' ) ) or ( ' row ' .. tostring ( i ) )
end
return ' rows= ' .. tostring ( # tbl.rows ) .. ' sample= ' .. table.concat ( parts , ' , ' )
end
local function _cache_formats_from_raw_info ( url , raw , source_label )
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
return nil , ' missing url '
end
local tbl , err = _build_formats_table_from_raw_info ( url , raw )
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' then
return nil , err or ' raw format conversion failed '
end
_cache_formats_for_url ( url , tbl )
local summary = _summarize_formats_table ( tbl , 8 )
local signature = url .. ' | ' .. summary
if signature ~= _last_raw_format_summary then
_last_raw_format_summary = signature
_lua_log ( ' formats: cached from ytdl-raw-info source= ' .. tostring ( source_label or ' unknown ' ) .. ' ' .. summary )
end
return tbl , nil
end
local function _build_format_picker_items ( tbl )
local items = { }
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' then
return items
end
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
return items
end
local function _open_format_picker_for_table ( url , tbl )
if type ( tbl ) ~= ' table ' or type ( tbl.rows ) ~= ' table ' or # tbl.rows == 0 then
mp.osd_message ( ' No formats available ' , 4 )
return false
end
_pending_format_change = _pending_format_change or { url = url , token = ' cached ' }
_pending_format_change.url = url
_pending_format_change.formats_table = tbl
local items = _build_format_picker_items ( tbl )
_debug_dump_formatted_formats ( url , tbl , items )
_show_format_list_osd ( items , 8 )
_uosc_open_list_picker ( DOWNLOAD_FORMAT_MENU_TYPE , ' Change format ' , items )
return true
end
local function _cache_formats_from_current_playback ( reason , raw )
local target = _current_url_for_web_actions ( ) or _current_target ( )
if not target or target == ' ' then
return false , ' no current target '
end
local url = tostring ( target )
if not _is_http_url ( url ) then
return false , ' target not http '
end
local tbl , err = _cache_formats_from_raw_info ( url , raw , reason )
if type ( tbl ) == ' table ' and type ( tbl.rows ) == ' table ' then
_lua_log ( ' formats: playback cache ready source= ' .. tostring ( reason or ' unknown ' ) .. ' rows= ' .. tostring ( # tbl.rows ) )
if type ( _pending_format_change ) == ' table '
and tostring ( _pending_format_change.url or ' ' ) == url
and type ( _pending_format_change.formats_table ) ~= ' table ' then
_lua_log ( ' change-format: fulfilling pending picker from playback cache ' )
_open_format_picker_for_table ( url , tbl )
end
return true , nil
end
return false , err
end
local function _run_formats_probe_async ( url , cb )
cb = cb or function ( ) end
url = trim ( tostring ( url or ' ' ) )
if url == ' ' then
cb ( nil , ' missing url ' )
return
end
local python = _resolve_python_exe ( false )
if not python or python == ' ' then
cb ( nil , ' no python executable available ' )
return
end
local probe_script = _detect_format_probe_script ( )
if probe_script == ' ' then
cb ( nil , ' format_probe.py not found ' )
return
end
local cwd = _detect_repo_root ( )
_lua_log ( ' formats-probe: spawning subprocess python= ' .. tostring ( python ) .. ' script= ' .. tostring ( probe_script ) .. ' cwd= ' .. tostring ( cwd or ' ' ) .. ' url= ' .. tostring ( url ) )
mp.command_native_async (
{
name = ' subprocess ' ,
args = { python , probe_script , url } ,
capture_stdout = true ,
capture_stderr = true ,
playback_only = false ,
} ,
function ( success , result , err )
if not success then
cb ( nil , tostring ( err or ' subprocess failed ' ) )
return
end
if type ( result ) ~= ' table ' then
cb ( nil , ' invalid subprocess result ' )
return
end
local status = tonumber ( result.status or 0 ) or 0
local stdout = trim ( tostring ( result.stdout or ' ' ) )
local stderr = trim ( tostring ( result.stderr or ' ' ) )
if stdout == ' ' then
local detail = stderr
if detail == ' ' then
detail = tostring ( result.error or ( ' format probe exited with status ' .. tostring ( status ) ) )
end
cb ( nil , detail )
return
end
local ok , payload = pcall ( utils.parse_json , stdout )
if ( not ok or type ( payload ) ~= ' table ' ) and stdout ~= ' ' then
local last_json_line = nil
for line in stdout : gmatch ( ' [^ \r \n ]+ ' ) do
line = trim ( tostring ( line or ' ' ) )
if line ~= ' ' then
last_json_line = line
end
end
if last_json_line and last_json_line ~= stdout then
_lua_log ( ' formats-probe: retrying json parse from last stdout line ' )
ok , payload = pcall ( utils.parse_json , last_json_line )
end
end
if not ok or type ( payload ) ~= ' table ' then
cb ( nil , ' invalid format probe json ' )
return
end
if not payload.success then
local detail = tostring ( payload.error or payload.stderr or stderr or ' format probe failed ' )
cb ( payload , detail )
return
end
cb ( payload , nil )
end
)
end
local function _schedule_playback_format_cache_poll ( url , generation , attempt )
if type ( url ) ~= ' string ' or url == ' ' or generation ~= _format_cache_poll_generation then
return
end
if _get_cached_formats_table ( url ) then
return
end
attempt = tonumber ( attempt or 1 ) or 1
local ok = select ( 1 , _cache_formats_from_current_playback ( ' poll- ' .. tostring ( attempt ) ) )
if ok then
return
end
if attempt >= 12 then
_lua_log ( ' formats: playback cache poll exhausted for url= ' .. url )
return
end
mp.add_timeout ( 0.5 , function ( )
_schedule_playback_format_cache_poll ( url , generation , attempt + 1 )
end )
end
2025-12-19 02:29:42 -08:00
function FileState : fetch_formats ( cb )
local url = tostring ( self.url or ' ' )
2026-02-06 23:34:20 -08:00
_lua_log ( ' fetch-formats: started for url= ' .. url )
2025-12-18 22:50:21 -08:00
if url == ' ' or not _is_http_url ( url ) then
2026-02-06 23:34:20 -08:00
_lua_log ( ' fetch-formats: skipped (not a url) ' )
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
if _extract_store_hash ( url ) then
2026-02-06 23:34:20 -08:00
_lua_log ( ' fetch-formats: skipped (store-hash) ' )
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
2026-03-26 23:00:25 -07:00
if not _is_ytdlp_url ( url ) then
_lua_log ( ' fetch-formats: skipped (yt-dlp unsupported) ' )
if cb then cb ( false , ' yt-dlp unsupported url ' ) end
return
end
2025-12-19 02:29:42 -08:00
local cached = _get_cached_formats_table ( url )
if type ( cached ) == ' table ' then
2026-02-06 23:34:20 -08:00
_lua_log ( ' fetch-formats: using cached table ' )
2025-12-19 02:29:42 -08:00
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
2026-03-18 01:26:55 -07:00
local raw_cached , raw_err = _cache_formats_from_raw_info ( url , nil , ' fetch ' )
if type ( raw_cached ) == ' table ' then
self : set_formats ( url , raw_cached )
_lua_log ( ' fetch-formats: satisfied directly from ytdl-raw-info ' )
if cb then cb ( true , nil ) end
return
end
if raw_err and raw_err ~= ' ' then
_lua_log ( ' fetch-formats: ytdl-raw-info unavailable reason= ' .. tostring ( raw_err ) )
end
2026-02-06 23:34:20 -08:00
local function _perform_request ( )
if _formats_inflight [ url ] then
_lua_log ( ' fetch-formats: already inflight, adding waiter ' )
_formats_waiters [ url ] = _formats_waiters [ url ] or { }
if cb then table.insert ( _formats_waiters [ url ] , cb ) end
return
end
2026-03-18 01:26:55 -07:00
_lua_log ( ' fetch-formats: initiating subprocess probe ' )
2026-02-06 23:34:20 -08:00
_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
2026-03-18 01:26:55 -07:00
_run_formats_probe_async ( url , function ( resp , err )
_lua_log ( ' fetch-formats: subprocess callback received err= ' .. tostring ( err ) )
2026-02-06 23:34:20 -08:00
_formats_inflight [ url ] = nil
local ok = false
local reason = err
if resp and resp.success and type ( resp.table ) == ' table ' then
ok = true
reason = nil
self : set_formats ( url , resp.table )
_cache_formats_for_url ( url , resp.table )
2026-03-18 01:26:55 -07:00
_lua_log ( ' formats: cached ' .. tostring ( ( resp.table . rows and # resp.table . rows ) or 0 ) .. ' rows for url via subprocess probe ' )
2026-02-06 23:34:20 -08:00
else
_lua_log ( ' fetch-formats: request failed success= ' .. tostring ( resp and resp.success ) )
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
2025-12-19 02:29:42 -08:00
end
end
2026-02-06 23:34:20 -08:00
local waiters = _formats_waiters [ url ] or { }
_lua_log ( ' fetch-formats: calling ' .. tostring ( # waiters ) .. ' waiters with ok= ' .. tostring ( ok ) .. ' reason= ' .. tostring ( reason ) )
_formats_waiters [ url ] = nil
for _ , fn in ipairs ( waiters ) do
pcall ( fn , ok , reason )
end
end )
end
_perform_request ( )
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
local function _prefetch_formats_for_url ( url , attempt )
2025-12-19 02:29:42 -08:00
url = tostring ( url or ' ' )
if url == ' ' or not _is_http_url ( url ) then
return
end
2026-03-26 23:00:25 -07:00
if not _is_ytdlp_url ( url ) then
_formats_prefetch_retries [ url ] = nil
_lua_log ( ' prefetch-formats: skipped (yt-dlp unsupported) url= ' .. url )
return
end
2026-03-18 01:26:55 -07:00
attempt = tonumber ( attempt or 1 ) or 1
local cached = _get_cached_formats_table ( url )
if type ( cached ) == ' table ' and type ( cached.rows ) == ' table ' and # cached.rows > 0 then
_formats_prefetch_retries [ url ] = nil
return
end
local raw_cached = nil
raw_cached = select ( 1 , _cache_formats_from_raw_info ( url , nil , ' prefetch- ' .. tostring ( attempt ) ) )
if type ( raw_cached ) == ' table ' and type ( raw_cached.rows ) == ' table ' and # raw_cached.rows > 0 then
_formats_prefetch_retries [ url ] = nil
_lua_log ( ' prefetch-formats: satisfied directly from ytdl-raw-info on attempt= ' .. tostring ( attempt ) )
return
end
2026-03-16 04:43:46 -07:00
_set_current_web_url ( url )
2025-12-19 02:29:42 -08:00
if type ( M.file ) == ' table ' then
2026-03-16 04:43:46 -07:00
if M.file . set_url then
M.file : set_url ( url )
else
M.file . url = url
end
2025-12-19 02:29:42 -08:00
if M.file . fetch_formats then
2026-03-18 01:26:55 -07:00
M.file : fetch_formats ( function ( ok , err )
if ok then
_formats_prefetch_retries [ url ] = nil
_lua_log ( ' prefetch-formats: cached formats for url on attempt= ' .. tostring ( attempt ) )
return
end
local reason = tostring ( err or ' ' )
local retryable = reason == ' helper not running '
or reason == ' helper not ready '
or reason : find ( ' timeout waiting response ' , 1 , true ) ~= nil
if not retryable then
_formats_prefetch_retries [ url ] = nil
_lua_log ( ' prefetch-formats: giving up for url reason= ' .. reason )
return
end
local delays = { 1.5 , 4.0 , 8.0 }
if attempt > # delays then
_formats_prefetch_retries [ url ] = nil
_lua_log ( ' prefetch-formats: retries exhausted for url reason= ' .. reason )
return
end
local next_attempt = attempt + 1
if ( _formats_prefetch_retries [ url ] or 0 ) >= next_attempt then
return
end
_formats_prefetch_retries [ url ] = next_attempt
local delay = delays [ attempt ]
_lua_log ( ' prefetch-formats: scheduling retry attempt= ' .. tostring ( next_attempt ) .. ' delay= ' .. tostring ( delay ) .. ' s reason= ' .. reason )
mp.add_timeout ( delay , function ( )
if type ( _get_cached_formats_table ( url ) ) == ' table ' then
_formats_prefetch_retries [ url ] = nil
return
end
_prefetch_formats_for_url ( url , next_attempt )
end )
end )
2025-12-19 02:29:42 -08:00
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
2026-03-18 01:26:55 -07:00
_debug_dump_formatted_formats = function ( url , tbl , items )
2025-12-19 02:29:42 -08:00
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
2026-03-18 01:26:55 -07:00
_show_format_list_osd = function ( items , max_items )
2026-02-06 23:34:20 -08:00
if type ( items ) ~= ' table ' then
return
end
local total = # items
if total == 0 then
mp.osd_message ( ' No formats available ' , 4 )
return
end
local limit = max_items or 8
if limit < 1 then
limit = 1
end
local lines = { }
for i = 1 , math.min ( total , limit ) do
local it = items [ i ] or { }
local title = tostring ( it.title or ' ' )
local hint = tostring ( it.hint or ' ' )
if hint ~= ' ' then
lines [ # lines + 1 ] = title .. ' — ' .. hint
else
lines [ # lines + 1 ] = title
end
end
if total > limit then
lines [ # lines + 1 ] = ' … + ' .. tostring ( total - limit ) .. ' more '
end
if # lines > 0 then
mp.osd_message ( table.concat ( lines , ' \n ' ) , 6 )
end
end
2025-12-18 22:50:21 -08:00
local function _current_ytdl_format_string ( )
2026-03-31 23:30:57 -07:00
local url = _current_url_for_web_actions ( ) or _current_target ( ) or ' '
local raw = mp.get_property_native ( ' ytdl-raw-info ' )
2025-12-18 22:50:21 -08:00
-- Preferred: mpv exposes the active ytdl format string.
local fmt = trim ( tostring ( mp.get_property_native ( ' ytdl-format ' ) or ' ' ) )
2026-03-31 23:30:57 -07:00
local suspicious_reason = M._suspicious_ytdl_format_reason ( fmt , url , raw )
if fmt ~= ' ' and not suspicious_reason then
2025-12-18 22:50:21 -08:00
return fmt
2026-03-31 23:30:57 -07:00
elseif fmt ~= ' ' then
_lua_log ( ' ytdl-format: ignoring suspicious current format source=ytdl-format value= ' .. tostring ( fmt ) .. ' reason= ' .. tostring ( suspicious_reason ) )
2025-12-18 22:50:21 -08:00
end
-- Fallbacks: option value, or raw info if available.
local opt = trim ( tostring ( mp.get_property ( ' options/ytdl-format ' ) or ' ' ) )
2026-03-31 23:30:57 -07:00
suspicious_reason = M._suspicious_ytdl_format_reason ( opt , url , raw )
if opt ~= ' ' and not suspicious_reason then
2025-12-18 22:50:21 -08:00
return opt
2026-03-31 23:30:57 -07:00
elseif opt ~= ' ' then
_lua_log ( ' ytdl-format: ignoring suspicious current format source=options/ytdl-format value= ' .. tostring ( opt ) .. ' reason= ' .. tostring ( suspicious_reason ) )
2025-12-18 22:50:21 -08:00
end
if type ( raw ) == ' table ' then
if raw.format_id and tostring ( raw.format_id ) ~= ' ' then
2026-03-31 23:30:57 -07:00
local raw_format_id = tostring ( raw.format_id )
suspicious_reason = M._suspicious_ytdl_format_reason ( raw_format_id , url , raw )
if not suspicious_reason then
return raw_format_id
end
_lua_log ( ' ytdl-format: ignoring suspicious current format source=ytdl-raw-info.format_id value= ' .. tostring ( raw_format_id ) .. ' reason= ' .. tostring ( suspicious_reason ) )
2025-12-18 22:50:21 -08:00
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
2026-03-31 23:30:57 -07:00
local joined = table.concat ( parts , ' + ' )
suspicious_reason = M._suspicious_ytdl_format_reason ( joined , url , raw )
if not suspicious_reason then
return joined
end
_lua_log ( ' ytdl-format: ignoring suspicious current format source=ytdl-raw-info.requested_formats value= ' .. tostring ( joined ) .. ' reason= ' .. tostring ( suspicious_reason ) )
2025-12-18 22:50:21 -08:00
end
end
end
return nil
end
2026-03-18 01:26:55 -07:00
local function _resolve_cli_entrypoint ( )
local configured = trim ( tostring ( ( opts and opts.cli_path ) or ' ' ) )
if configured ~= ' ' and configured ~= ' CLI.py ' then
if configured : match ( ' [/ \\ ] ' ) then
if _path_exists ( configured ) then
return configured
end
else
return configured
end
end
local repo_root = _detect_repo_root ( )
if repo_root ~= ' ' then
local candidate = utils.join_path ( repo_root , ' CLI.py ' )
if _path_exists ( candidate ) then
return candidate
end
end
return configured ~= ' ' and configured or ' CLI.py '
end
2026-03-18 20:17:28 -07:00
local function _build_pipeline_cli_args ( pipeline_cmd , seeds )
2026-03-18 01:26:55 -07:00
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
2026-03-18 20:17:28 -07:00
return nil , ' empty pipeline command '
2026-03-18 01:26:55 -07:00
end
local python = _resolve_python_exe ( true )
if not python or python == ' ' then
python = _resolve_python_exe ( false )
end
if not python or python == ' ' then
2026-03-18 20:17:28 -07:00
return nil , ' python not found '
2026-03-18 01:26:55 -07:00
end
local cli_path = _resolve_cli_entrypoint ( )
local args = { python , cli_path , ' pipeline ' , ' --pipeline ' , pipeline_cmd }
if seeds ~= nil then
local seeds_json = utils.format_json ( seeds )
if type ( seeds_json ) == ' string ' and seeds_json ~= ' ' then
args [ # args + 1 ] = ' --seeds-json '
args [ # args + 1 ] = seeds_json
end
end
2026-03-18 20:17:28 -07:00
return args , nil
end
local function _run_pipeline_cli_detached ( pipeline_cmd , seeds )
local args , build_err = _build_pipeline_cli_args ( pipeline_cmd , seeds )
if type ( args ) ~= ' table ' then
return false , tostring ( build_err or ' invalid pipeline args ' )
end
2026-03-18 01:26:55 -07:00
local cmd = {
name = ' subprocess ' ,
args = args ,
detach = true ,
}
local ok , result , detail = _run_subprocess_command ( cmd )
if ok then
_lua_log ( ' pipeline-detached: spawned via cli cmd= ' .. tostring ( pipeline_cmd ) )
return true , detail
end
_lua_log ( ' pipeline-detached: cli spawn failed detail= ' .. tostring ( detail or _describe_subprocess_result ( result ) ) )
return false , detail or _describe_subprocess_result ( result )
end
2026-03-18 12:24:37 -07:00
_run_pipeline_detached = function ( pipeline_cmd , on_failure , seeds )
2025-12-18 22:50:21 -08:00
if not pipeline_cmd or pipeline_cmd == ' ' then
return false
end
2026-03-18 12:24:37 -07:00
local ok , detail = _run_pipeline_cli_detached ( pipeline_cmd , seeds )
2026-03-18 01:26:55 -07:00
if ok then
return true
end
2026-01-07 05:09:59 -08:00
ensure_mpv_ipc_server ( )
if not ensure_pipeline_helper_running ( ) then
if type ( on_failure ) == ' function ' then
2026-03-18 01:26:55 -07:00
on_failure ( nil , detail ~= ' ' and detail or ' helper not running ' )
2026-01-07 05:09:59 -08:00
end
return false
end
2026-03-18 01:26:55 -07:00
2026-03-18 12:24:37 -07:00
_run_helper_request_async ( { op = ' run-detached ' , data = { pipeline = pipeline_cmd , seeds = seeds } } , 1.0 , function ( resp , err )
2026-01-07 05:09:59 -08:00
if resp and resp.success then
return
end
if type ( on_failure ) == ' function ' then
2026-03-18 01:26:55 -07:00
on_failure ( resp , err or detail )
2026-01-07 05:09:59 -08:00
end
end )
return true
2025-12-18 22:50:21 -08:00
end
2026-03-18 20:17:28 -07:00
_run_pipeline_background_job = function ( pipeline_cmd , seeds , on_started , on_complete , timeout_seconds , poll_interval_seconds )
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
if type ( on_complete ) == ' function ' then
on_complete ( nil , ' empty pipeline command ' )
end
return false
end
ensure_mpv_ipc_server ( )
if not ensure_pipeline_helper_running ( ) then
if type ( on_complete ) == ' function ' then
on_complete ( nil , ' helper not running ' )
end
return false
end
_run_helper_request_async ( { op = ' run-background ' , data = { pipeline = pipeline_cmd , seeds = seeds } } , 8.0 , function ( resp , err )
if err or not resp or not resp.success then
if type ( on_complete ) == ' function ' then
on_complete ( nil , err or ( resp and resp.error ) or ' failed to start background job ' )
end
return
end
local job_id = trim ( tostring ( resp.job_id or ' ' ) )
if job_id == ' ' then
if type ( on_complete ) == ' function ' then
on_complete ( nil , ' missing background job id ' )
end
return
end
if type ( on_started ) == ' function ' then
on_started ( job_id , resp )
end
local deadline = mp.get_time ( ) + math.max ( tonumber ( timeout_seconds or 0 ) or 0 , 15.0 )
local poll_interval = math.max ( tonumber ( poll_interval_seconds or 0 ) or 0 , 0.25 )
local poll_inflight = false
local timer = nil
local function finish ( job , finish_err )
if timer then
timer : kill ( )
timer = nil
end
if type ( on_complete ) == ' function ' then
on_complete ( job , finish_err )
end
end
timer = mp.add_periodic_timer ( poll_interval , function ( )
if poll_inflight then
return
end
if mp.get_time ( ) >= deadline then
finish ( nil , ' timeout waiting background job ' )
return
end
poll_inflight = true
_run_helper_request_async ( { op = ' job-status ' , data = { job_id = job_id } , quiet = true } , math.max ( poll_interval + 0.75 , 1.25 ) , function ( status_resp , status_err )
poll_inflight = false
if status_err then
_lua_log ( ' background-job: poll retry job= ' .. tostring ( job_id ) .. ' err= ' .. tostring ( status_err ) )
return
end
if not status_resp or not status_resp.success then
local status_error = status_resp and status_resp.error or ' job status unavailable '
finish ( nil , status_error )
return
end
local job = status_resp.job
local status = type ( job ) == ' table ' and tostring ( job.status or ' ' ) or tostring ( status_resp.status or ' ' )
if status == ' success ' or status == ' failed ' then
finish ( job , nil )
end
end )
end )
end )
return true
end
2025-12-18 22:50:21 -08:00
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
2026-03-22 22:41:56 -07:00
_ensure_selected_store_loaded ( )
2026-03-20 23:27:35 -07:00
local clip_range = trim ( tostring ( _pending_download.clip_range or ' ' ) )
local title = clip_range ~= ' ' and ( ' Save clip ' .. clip_range ) or ' Save location '
2025-12-18 22:50:21 -08:00
local function build_items ( )
2026-03-18 01:26:55 -07:00
local selected = _get_selected_store ( )
2025-12-18 22:50:21 -08:00
local items = {
{
title = ' Pick folder… ' ,
2026-03-20 23:27:35 -07:00
hint = clip_range ~= ' ' and ( ' Save clip ' .. clip_range .. ' to a local folder ' ) or ' Save to a local folder ' ,
2025-12-18 22:50:21 -08:00
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 }
2026-03-18 01:26:55 -07:00
local hint = _store_status_hint_for_url ( name , tostring ( _pending_download.url or ' ' ) , nil )
if ( not hint or hint == ' ' ) and selected ~= ' ' and name == selected then
hint = ' Current store '
end
2025-12-18 22:50:21 -08:00
items [ # items + 1 ] = {
title = name ,
2026-03-18 01:26:55 -07:00
hint = hint ,
active = ( selected ~= ' ' and name == selected ) and true or false ,
2025-12-18 22:50:21 -08:00
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.
2026-03-20 23:27:35 -07:00
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , title , build_items ( ) )
2025-12-18 22:50:21 -08:00
-- 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
2026-01-07 05:09:59 -08:00
_refresh_store_cache ( 1.5 , function ( success , changed )
if success and changed then
2026-03-20 23:27:35 -07:00
_uosc_open_list_picker ( DOWNLOAD_STORE_MENU_TYPE , title , build_items ( ) )
2025-12-18 22:50:21 -08:00
end
2026-01-07 05:09:59 -08:00
end )
2025-12-18 22:50:21 -08:00
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
2026-03-18 01:26:55 -07:00
pcall ( _refresh_current_store_url_status , ' startup ' )
2025-12-18 22:50:21 -08:00
end )
local function _apply_ytdl_format_and_reload ( url , fmt )
if not url or url == ' ' or not fmt or fmt == ' ' then
return
end
2026-03-31 23:30:57 -07:00
local suspicious_reason = M._suspicious_ytdl_format_reason ( fmt , url , mp.get_property_native ( ' ytdl-raw-info ' ) )
if suspicious_reason then
pcall ( mp.set_property , ' options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' file-local-options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' ytdl-format ' , ' ' )
_lua_log ( ' change-format: rejected suspicious format= ' .. tostring ( fmt ) .. ' reason= ' .. tostring ( suspicious_reason ) .. ' url= ' .. tostring ( url ) )
mp.osd_message ( ' Invalid format selection ' , 3 )
return
end
2025-12-18 22:50:21 -08:00
local pos = mp.get_property_number ( ' time-pos ' )
local paused = mp.get_property_native ( ' pause ' ) and true or false
2026-03-18 01:26:55 -07:00
local media_title = trim ( tostring ( mp.get_property ( ' media-title ' ) or ' ' ) )
2025-12-18 22:50:21 -08:00
2026-03-16 04:43:46 -07:00
_lua_log ( ' change-format: setting ytdl format= ' .. tostring ( fmt ) )
2026-03-18 01:26:55 -07:00
_skip_next_store_check_url = _normalize_url_for_store_lookup ( url )
2026-03-16 04:43:46 -07:00
_set_current_web_url ( url )
2025-12-18 22:50:21 -08:00
pcall ( mp.set_property , ' options/ytdl-format ' , tostring ( fmt ) )
2026-03-16 04:43:46 -07:00
pcall ( mp.set_property , ' file-local-options/ytdl-format ' , tostring ( fmt ) )
pcall ( mp.set_property , ' ytdl-format ' , tostring ( fmt ) )
2025-12-18 22:50:21 -08:00
2026-03-18 01:26:55 -07:00
local load_options = {
[ ' ytdl-format ' ] = tostring ( fmt ) ,
}
2025-12-18 22:50:21 -08:00
if pos and pos > 0 then
2026-03-18 01:26:55 -07:00
load_options [ ' start ' ] = tostring ( pos )
2025-12-18 22:50:21 -08:00
end
2026-03-18 01:26:55 -07:00
if media_title ~= ' ' then
load_options [ ' force-media-title ' ] = media_title
end
if paused then
load_options [ ' pause ' ] = ' yes '
end
_lua_log ( ' change-format: reloading current url with per-file options ' )
mp.command_native ( { ' loadfile ' , url , ' replace ' , - 1 , load_options } )
2025-12-18 22:50:21 -08:00
if paused then
mp.set_property_native ( ' pause ' , true )
end
end
local function _start_download_flow_for_current ( )
2026-03-16 04:43:46 -07:00
local target = _current_url_for_web_actions ( ) or _current_target ( )
2025-12-18 22:50:21 -08:00
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
2026-03-22 22:41:56 -07:00
M._pick_folder_async ( function ( folder , err )
if err and err ~= ' ' then
mp.osd_message ( ' Folder picker failed: ' .. tostring ( err ) , 4 )
return
end
if not folder or folder == ' ' then
return
end
ensure_mpv_ipc_server ( )
local pipeline_cmd = ' get-file -store ' .. quote_pipeline_arg ( store_hash.store ) .. ' -query ' .. quote_pipeline_arg ( ' hash: ' .. store_hash.hash ) .. ' -path ' .. quote_pipeline_arg ( folder )
_queue_pipeline_in_repl (
pipeline_cmd ,
' Queued in REPL: store copy ' ,
' REPL queue failed ' ,
' download-store-copy ' ,
{
mpv_notify = {
success_text = ' Copy completed: store ' .. tostring ( store_hash.store ) ,
failure_text = ' Copy failed: store ' .. tostring ( store_hash.store ) ,
duration_ms = 3500 ,
} ,
}
)
end )
2025-12-18 22:50:21 -08:00
return
end
-- Non-store URL flow: use the current yt-dlp-selected format and ask for save location.
local url = tostring ( target )
2026-03-19 13:08:15 -07:00
local download_url , stripped_playlist = _download_url_for_current_item ( url )
if stripped_playlist then
_lua_log ( ' download: stripped hidden playlist params from current url -> ' .. tostring ( download_url ) )
url = tostring ( download_url )
end
2025-12-18 22:50:21 -08:00
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 ) )
2026-03-20 23:27:35 -07:00
local clip_range = _get_trim_range_from_clip_markers ( )
_pending_download = {
url = url ,
format = fmt ,
clip_range = clip_range ,
}
if clip_range and clip_range ~= ' ' then
_lua_log ( ' download: using clip_range= ' .. tostring ( clip_range ) )
else
local clip_marker_count = 0
local marker_label = nil
for idx = 1 , CLIP_MARKER_SLOT_COUNT do
local marker_time = clip_markers [ idx ]
if type ( marker_time ) == ' number ' then
clip_marker_count = clip_marker_count + 1
if not marker_label then
marker_label = _format_clip_marker_label ( marker_time )
end
end
end
if clip_marker_count == 1 then
_lua_log ( ' download: single clip marker detected; asking whether to continue with full download ' )
if not ensure_uosc_loaded ( ) then
mp.osd_message ( ' Only one clip marker is set. Set the second marker or clear markers before downloading. ' , 4 )
return
end
_uosc_open_list_picker ( ' medios_download_clip_decision ' , marker_label and ( ' One clip marker set: ' .. marker_label ) or ' One clip marker set ' , {
{
title = ' Download full item ' ,
hint = ' Ignore the single marker and continue ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-download-proceed-full ' , ' {} ' } ,
} ,
{
title = ' Clear clip markers and download full item ' ,
hint = ' Reset markers, then continue with a full download ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-download-clear-markers-and-proceed ' , ' {} ' } ,
} ,
{
title = ' Go back and edit clip markers ' ,
hint = ' Set the second marker or replace the existing one ' ,
value = { ' script-message-to ' , mp.get_script_name ( ) , ' medios-download-edit-markers ' , ' {} ' } ,
} ,
} )
return
end
end
2025-12-18 22:50:21 -08:00
_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 ( )
2026-03-16 04:43:46 -07:00
local target = _current_url_for_web_actions ( ) or _current_target ( )
2025-12-18 22:50:21 -08:00
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 )
2026-03-16 04:43:46 -07:00
_set_current_web_url ( url )
2025-12-18 22:50:21 -08:00
2025-12-19 02:29:42 -08:00
-- Ensure file state is tracking the current URL.
if type ( M.file ) == ' table ' then
2026-03-16 04:43:46 -07:00
if M.file . set_url then
M.file : set_url ( url )
else
M.file . url = url
end
2025-12-19 02:29:42 -08:00
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
2026-03-16 04:43:46 -07:00
if type ( M.file ) == ' table '
and M.file . url == url
and type ( M.file . formats ) == ' table ' then
2025-12-19 02:29:42 -08:00
cached_tbl = M.file . formats
else
cached_tbl = _get_cached_formats_table ( url )
end
2026-03-18 01:26:55 -07:00
if not ( type ( cached_tbl ) == ' table ' and type ( cached_tbl.rows ) == ' table ' and # cached_tbl.rows > 0 ) then
cached_tbl = select ( 1 , _cache_formats_from_raw_info ( 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 }
2026-03-18 01:26:55 -07:00
_open_format_picker_for_table ( url , cached_tbl )
2025-12-18 22:50:21 -08:00
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
_pending_format_change.formats_table = tbl
2026-03-18 01:26:55 -07:00
_open_format_picker_for_table ( url , tbl )
2025-12-19 02:29:42 -08:00
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 ( )
2026-03-16 04:43:46 -07:00
_sync_current_web_url_from_playback ( )
2026-03-22 22:41:56 -07:00
M._attempt_start_lyric_helper_async ( ' file-loaded ' )
M._schedule_web_subtitle_activation ( ' file-loaded ' )
2026-03-18 01:26:55 -07:00
_refresh_current_store_url_status ( ' file-loaded ' )
2026-03-16 04:43:46 -07:00
local target = _current_url_for_web_actions ( ) or _current_target ( )
2025-12-18 22:50:21 -08:00
if not target or target == ' ' then
return
end
local url = tostring ( target )
if not _is_http_url ( url ) then
return
end
2026-03-22 22:41:56 -07:00
mp.add_timeout ( 0.1 , function ( )
M._reset_uosc_input_state ( ' file-loaded-web ' )
end )
2026-03-18 01:26:55 -07:00
local ok , err = _cache_formats_from_current_playback ( ' file-loaded ' )
if ok then
_lua_log ( ' formats: file-loaded cache succeeded for url= ' .. url )
else
_lua_log ( ' formats: file-loaded cache pending reason= ' .. tostring ( err or ' unknown ' ) )
end
_format_cache_poll_generation = _format_cache_poll_generation + 1
_schedule_playback_format_cache_poll ( url , _format_cache_poll_generation , 1 )
2025-12-18 22:50:21 -08:00
_prefetch_formats_for_url ( url )
end )
2026-03-22 22:41:56 -07:00
mp.observe_property ( ' track-list ' , ' native ' , function ( )
M._ensure_current_subtitles_visible ( ' observe-track-list ' )
end )
2026-03-18 01:26:55 -07:00
mp.observe_property ( ' ytdl-raw-info ' , ' native ' , function ( _name , value )
if type ( value ) ~= ' table ' then
return
end
local ok , err = _cache_formats_from_current_playback ( ' observe-ytdl-raw-info ' , value )
if not ok and err and err ~= ' ' then
_lua_log ( ' formats: observe-ytdl-raw-info pending reason= ' .. tostring ( err ) )
end
end )
2025-12-18 22:50:21 -08:00
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-03-20 23:27:35 -07:00
local clip_range = trim ( tostring ( _pending_download.clip_range or ' ' ) )
local query = ' format: ' .. fmt
if clip_range ~= ' ' then
query = query .. ' ,clip: ' .. clip_range
end
local clip_suffix = clip_range ~= ' ' and ( ' [ ' .. clip_range .. ' ] ' ) or ' '
2025-12-18 22:50:21 -08:00
2026-03-18 01:26:55 -07:00
local pipeline_cmd = ' download-file -url ' .. quote_pipeline_arg ( url )
2026-03-20 23:27:35 -07:00
.. ' -query ' .. quote_pipeline_arg ( query )
2025-12-18 22:50:21 -08:00
.. ' | add-file -store ' .. quote_pipeline_arg ( store )
2026-03-18 01:26:55 -07:00
_set_selected_store ( store )
2026-03-18 20:17:28 -07:00
_queue_pipeline_in_repl (
pipeline_cmd ,
' Queued in REPL: save to store ' .. store ,
' REPL queue failed ' ,
2026-03-19 13:08:15 -07:00
' download-store-save ' ,
{
mpv_notify = {
2026-03-20 23:27:35 -07:00
success_text = ' Download completed: store ' .. store .. ' [ ' .. fmt .. ' ] ' .. clip_suffix ,
failure_text = ' Download failed: store ' .. store .. ' [ ' .. fmt .. ' ] ' .. clip_suffix ,
2026-03-19 13:08:15 -07:00
duration_ms = 3500 ,
} ,
}
2026-03-18 20:17:28 -07:00
)
2025-12-18 22:50:21 -08:00
_pending_download = nil
end )
2026-03-20 23:27:35 -07:00
mp.register_script_message ( ' medios-download-proceed-full ' , function ( )
if type ( _pending_download ) ~= ' table ' or not _pending_download.url or not _pending_download.format then
return
end
_pending_download.clip_range = nil
_open_save_location_picker_for_pending_download ( )
end )
mp.register_script_message ( ' medios-download-clear-markers-and-proceed ' , function ( )
if type ( _pending_download ) ~= ' table ' or not _pending_download.url or not _pending_download.format then
return
end
_reset_clip_markers ( )
_pending_download.clip_range = nil
_open_save_location_picker_for_pending_download ( )
end )
mp.register_script_message ( ' medios-download-edit-markers ' , function ( )
_pending_download = nil
mp.osd_message ( ' Edit clip markers, then run Download again ' , 4 )
end )
2025-12-18 22:50:21 -08:00
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
2026-03-22 22:41:56 -07:00
M._pick_folder_async ( function ( folder , err )
if err and err ~= ' ' then
mp.osd_message ( ' Folder picker failed: ' .. tostring ( err ) , 4 )
return
end
if not folder or folder == ' ' then
return
end
2025-12-18 22:50:21 -08:00
2026-03-22 22:41:56 -07:00
local url = tostring ( _pending_download.url )
local fmt = tostring ( _pending_download.format )
local clip_range = trim ( tostring ( _pending_download.clip_range or ' ' ) )
local query = ' format: ' .. fmt
if clip_range ~= ' ' then
query = query .. ' ,clip: ' .. clip_range
end
local clip_suffix = clip_range ~= ' ' and ( ' [ ' .. clip_range .. ' ] ' ) or ' '
2025-12-18 22:50:21 -08:00
2026-03-22 22:41:56 -07:00
local pipeline_cmd = ' download-file -url ' .. quote_pipeline_arg ( url )
.. ' -query ' .. quote_pipeline_arg ( query )
.. ' | add-file -path ' .. quote_pipeline_arg ( folder )
2025-12-18 22:50:21 -08:00
2026-03-22 22:41:56 -07:00
_queue_pipeline_in_repl (
pipeline_cmd ,
' Queued in REPL: save to folder ' ,
' REPL queue failed ' ,
' download-folder-save ' ,
{
mpv_notify = {
success_text = ' Download completed: folder [ ' .. fmt .. ' ] ' .. clip_suffix ,
failure_text = ' Download failed: folder [ ' .. fmt .. ' ] ' .. clip_suffix ,
duration_ms = 3500 ,
} ,
}
)
_pending_download = nil
end )
2025-12-18 22:50:21 -08:00
end )
2025-12-17 17:42:46 -08:00
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
2026-01-12 17:55:04 -08:00
-- Ref: mpv_lua_api.py was removed in favor of pipeline_helper (run_pipeline_via_ipc_response).
-- This placeholder comment ensures we don't have code shifting issues.
2025-11-27 10:59:01 -08:00
2026-01-03 03:37:48 -08:00
-- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response).
2026-01-07 05:09:59 -08:00
-- Calls the callback with stdout on success or error message on failure.
function M . run_pipeline ( pipeline_cmd , seeds , cb )
2026-02-03 17:14:11 -08:00
_lua_log ( ' M.run_pipeline called with cmd: ' .. tostring ( pipeline_cmd ) )
2026-01-07 05:09:59 -08:00
cb = cb or function ( ) end
2026-01-03 03:37:48 -08:00
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
2026-02-03 17:14:11 -08:00
_lua_log ( ' M.run_pipeline: empty command ' )
2026-01-07 05:09:59 -08:00
cb ( nil , ' empty pipeline command ' )
return
2026-01-03 03:37:48 -08:00
end
ensure_mpv_ipc_server ( )
2026-02-06 23:34:20 -08:00
-- Use a longer timeout for `.mpv -url` commands to avoid races with slow helper starts.
local lower_cmd = pipeline_cmd : lower ( )
local is_mpv_load = lower_cmd : match ( ' %.mpv%s+%-url ' ) ~= nil
local timeout_seconds = is_mpv_load and 45 or 30
2026-02-03 17:14:11 -08:00
_run_pipeline_request_async ( pipeline_cmd , seeds , timeout_seconds , function ( resp , err )
_lua_log ( ' M.run_pipeline callback fired: resp= ' .. tostring ( resp ) .. ' , err= ' .. tostring ( err ) )
2026-01-07 05:09:59 -08:00
if resp and resp.success then
2026-02-03 17:14:11 -08:00
_lua_log ( ' M.run_pipeline: success ' )
2026-01-07 05:09:59 -08:00
cb ( resp.stdout or ' ' , nil )
return
2026-01-03 03:37:48 -08:00
end
2026-01-07 05:09:59 -08:00
local details = err or ' '
if details == ' ' and type ( resp ) == ' table ' then
if resp.error and tostring ( resp.error ) ~= ' ' then
details = tostring ( resp.error )
elseif resp.stderr and tostring ( resp.stderr ) ~= ' ' then
details = tostring ( resp.stderr )
end
end
if details == ' ' then
details = ' unknown '
end
_lua_log ( ' pipeline failed cmd= ' .. tostring ( pipeline_cmd ) .. ' err= ' .. details )
cb ( nil , details )
end )
2026-01-03 03:37:48 -08:00
end
2025-11-27 10:59:01 -08:00
-- Helper to run pipeline and parse JSON output
2026-01-07 05:09:59 -08:00
function M . run_pipeline_json ( pipeline_cmd , seeds , cb )
cb = cb or function ( ) end
2026-03-18 12:24:37 -07:00
pipeline_cmd = trim ( tostring ( pipeline_cmd or ' ' ) )
if pipeline_cmd == ' ' then
cb ( nil , ' empty pipeline command ' )
return
2026-01-07 05:09:59 -08:00
end
2026-03-18 12:24:37 -07:00
ensure_mpv_ipc_server ( )
local lower_cmd = pipeline_cmd : lower ( )
local is_mpv_load = lower_cmd : match ( ' %.mpv%s+%-url ' ) ~= nil
local timeout_seconds = is_mpv_load and 45 or 30
_run_pipeline_request_async ( pipeline_cmd , seeds , timeout_seconds , function ( resp , err )
if resp and resp.success then
if type ( resp.data ) == ' table ' then
cb ( resp.data , nil )
return
end
local output = trim ( tostring ( resp.stdout or ' ' ) )
if output ~= ' ' then
local ok , data = pcall ( utils.parse_json , output )
if ok then
cb ( data , nil )
return
end
_lua_log ( ' Failed to parse JSON: ' .. output )
cb ( nil , ' malformed JSON response ' )
2026-01-07 05:09:59 -08:00
return
end
2026-03-18 12:24:37 -07:00
cb ( { } , nil )
2026-01-07 05:09:59 -08:00
return
2025-11-27 10:59:01 -08:00
end
2026-03-18 12:24:37 -07:00
local details = err or ' '
if details == ' ' and type ( resp ) == ' table ' then
if resp.error and tostring ( resp.error ) ~= ' ' then
details = tostring ( resp.error )
elseif resp.stderr and tostring ( resp.stderr ) ~= ' ' then
details = tostring ( resp.stderr )
end
end
cb ( nil , details ~= ' ' and details or ' unknown ' )
2026-01-07 05:09:59 -08:00
end )
2025-11-27 10:59:01 -08:00
end
-- Command: Get info for current file
function M . get_file_info ( )
2026-01-07 05:09:59 -08:00
local path = mp.get_property ( ' path ' )
if not path then
return
2025-11-27 10:59:01 -08:00
end
2026-01-07 05:09:59 -08:00
local seed = { { path = path } }
M.run_pipeline_json ( ' get-metadata ' , seed , function ( data , err )
if data then
_lua_log ( ' Metadata: ' .. utils.format_json ( data ) )
mp.osd_message ( ' Metadata loaded (check console) ' , 3 )
return
end
if err then
mp.osd_message ( ' Failed to load metadata: ' .. tostring ( err ) , 3 )
end
end )
2025-11-27 10:59:01 -08:00
end
-- Command: Delete current file
function M . delete_current_file ( )
2026-01-07 05:09:59 -08:00
local path = mp.get_property ( ' path ' )
if not path then
return
end
2025-11-27 10:59:01 -08:00
local seed = { { path = path } }
2026-01-07 05:09:59 -08:00
M.run_pipeline ( ' delete-file ' , seed , function ( _ , err )
if err then
mp.osd_message ( ' Delete failed: ' .. tostring ( err ) , 3 )
return
end
mp.osd_message ( ' File deleted ' , 3 )
mp.command ( ' playlist-next ' )
end )
2025-11-27 10:59:01 -08:00
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 ( )
2026-02-03 17:14:11 -08:00
_lua_log ( ' open_load_url_prompt called ' )
2025-12-17 17:42:46 -08:00
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 = { } ,
}
2026-03-22 22:41:56 -07:00
if M._open_uosc_menu ( menu_data , ' load-url-prompt ' ) then
2026-02-03 17:14:11 -08:00
_lua_log ( ' open_load_url_prompt: sending menu to uosc ' )
2025-12-18 22:50:21 -08:00
else
2026-02-03 17:14:11 -08:00
_lua_log ( ' menu: uosc not available; cannot open-menu for load-url ' )
2025-12-18 22:50:21 -08:00
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 ,
}
2026-03-22 22:41:56 -07:00
if not M._open_uosc_menu ( menu_data , ' cmd-menu ' ) then
2025-12-23 16:36:39 -08:00
_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 )
local trim_module = nil
2026-03-15 01:51:10 -07:00
local trim_path = nil
2025-12-27 03:13:16 -08:00
local load_err = nil
2026-03-15 01:51:10 -07:00
local ok_trim = false
2025-12-27 03:13:16 -08:00
2026-03-15 01:51:10 -07:00
ok_trim , trim_module , trim_path , load_err = _load_lua_chunk_from_candidates ( ' trim ' , ' trim.lua ' )
2025-12-27 03:13:16 -08:00
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
2026-03-15 01:51:10 -07:00
if ok_trim and trim_path then
_lua_log ( ' trim: using module at ' .. tostring ( trim_path ) )
end
2025-12-27 03:13:16 -08:00
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 )
2026-01-12 17:55:04 -08:00
_lua_log ( ' trim: === CALLING PIPELINE HELPER FOR UPLOAD === ' )
2025-12-27 03:13:16 -08:00
2026-03-18 12:24:37 -07:00
run_pipeline_via_ipc_async ( pipeline_cmd , nil , 60 , function ( response , err )
if not response then
response = { success = false , error = err or ' Timeout or IPC error ' }
2025-12-27 03:13:16 -08:00
end
2026-03-18 12:24:37 -07:00
_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 = err or response.error or response.stderr or ' unknown error '
mp.osd_message ( ' Upload failed: ' .. err_msg , 5 )
_lua_log ( ' trim: upload FAILED - ' .. err_msg )
end
end )
2025-12-23 16:36:39 -08:00
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 ' } ,
}
}
}
2026-03-22 22:41:56 -07:00
if not M._open_uosc_menu ( menu_data , ' trim-prompt ' ) then
2025-12-23 16:36:39 -08:00
_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 ( )
2026-02-03 17:14:11 -08:00
_lua_log ( ' medios-load-url handler called ' )
2026-03-22 22:41:56 -07:00
_lua_log ( ' medios-load-url: resetting uosc input state before opening Load URL prompt ' )
M._reset_uosc_input_state ( ' medios-load-url ' )
2025-12-17 17:42:46 -08:00
M.open_load_url_prompt ( )
end )
2026-02-03 17:14:11 -08:00
mp.register_script_message ( ' medios-start-helper ' , function ( )
-- Asynchronously start the pipeline helper without blocking the menu.
attempt_start_pipeline_helper_async ( function ( success )
if success then
mp.osd_message ( ' Pipeline helper started ' , 2 )
else
mp.osd_message ( ' Failed to start pipeline helper (check logs) ' , 3 )
end
end )
end )
2025-12-17 17:42:46 -08:00
mp.register_script_message ( ' medios-load-url-event ' , function ( json )
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Event handler called with: ' .. tostring ( json or ' nil ' ) )
2025-12-17 17:42:46 -08:00
local ok , event = pcall ( utils.parse_json , json )
if not ok or type ( event ) ~= ' table ' then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Failed to parse JSON: ' .. tostring ( json ) )
2026-02-03 17:14:11 -08:00
mp.osd_message ( ' Failed to parse URL ' , 2 )
if ensure_uosc_loaded ( ) then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Closing menu due to parse error ' )
2026-02-03 17:14:11 -08:00
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , LOAD_URL_MENU_TYPE )
end
2025-12-17 17:42:46 -08:00
return
end
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Parsed event: type= ' .. tostring ( event.type ) .. ' , query= ' .. tostring ( event.query ) )
2025-12-17 17:42:46 -08:00
if event.type ~= ' search ' then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Event type is not search: ' .. tostring ( event.type ) )
2026-02-03 17:14:11 -08:00
if ensure_uosc_loaded ( ) then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Closing menu due to type mismatch ' )
2026-02-03 17:14:11 -08:00
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , LOAD_URL_MENU_TYPE )
end
2025-12-17 17:42:46 -08:00
return
end
local url = trim ( tostring ( event.query or ' ' ) )
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Trimmed URL: " ' .. url .. ' " ' )
2025-12-17 17:42:46 -08:00
if url == ' ' then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] URL is empty ' )
2026-02-04 16:59:04 -08:00
_log_all ( ' ERROR ' , ' Load URL failed: URL is empty ' )
2026-02-03 17:14:11 -08:00
mp.osd_message ( ' URL is empty ' , 2 )
if ensure_uosc_loaded ( ) then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Closing menu due to empty URL ' )
2026-02-03 17:14:11 -08:00
mp.commandv ( ' script-message-to ' , ' uosc ' , ' close-menu ' , LOAD_URL_MENU_TYPE )
end
2025-12-17 17:42:46 -08:00
return
end
2026-02-03 17:14:11 -08:00
mp.osd_message ( ' Loading URL... ' , 1 )
2026-02-04 16:59:04 -08:00
_log_all ( ' INFO ' , ' Load URL started: ' .. url )
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Starting to load: ' .. url )
2026-03-16 04:43:46 -07:00
_set_current_web_url ( url )
2026-03-26 23:00:25 -07:00
_pending_format_change = nil
pcall ( mp.set_property , ' options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' file-local-options/ytdl-format ' , ' ' )
pcall ( mp.set_property , ' ytdl-format ' , ' ' )
_lua_log ( ' load-url: cleared stale ytdl format reason=load-url ' )
2026-02-03 17:14:11 -08:00
local function close_menu ( )
2026-03-22 22:41:56 -07:00
_lua_log ( ' [LOAD-URL] Closing menu and resetting input state ' )
if not M._reset_uosc_input_state ( ' load-url-submit ' ) then
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] UOSC not loaded, cannot close menu ' )
2026-02-03 17:14:11 -08:00
end
end
2026-03-16 04:43:46 -07:00
-- Close the URL prompt immediately once the user submits. Playback may still
-- take time to resolve, but the modal should not stay stuck on screen.
close_menu ( )
2026-02-03 17:14:11 -08:00
-- First, always try direct loadfile. This is the fastest path.
local can_direct = _url_can_direct_load ( url )
2026-03-16 04:43:46 -07:00
local prefer_direct = can_direct or _is_ytdlp_url ( url )
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Checking if URL can be loaded directly: ' .. tostring ( can_direct ) )
2026-03-16 04:43:46 -07:00
_lua_log ( ' [LOAD-URL] Prefer direct load: ' .. tostring ( prefer_direct ) )
2026-02-06 23:34:20 -08:00
2026-03-16 04:43:46 -07:00
local direct_ok , direct_loaded = _try_direct_loadfile ( url , prefer_direct )
2026-02-06 23:34:20 -08:00
if direct_ok and direct_loaded then
_lua_log ( ' [LOAD-URL] Direct loadfile command sent successfully (forced) ' )
_log_all ( ' INFO ' , ' Load URL succeeded via direct load ' )
mp.osd_message ( ' URL loaded ' , 2 )
return
end
if direct_ok then
_lua_log ( ' [LOAD-URL] Direct loadfile command did not load the URL; falling back to helper ' )
else
_lua_log ( ' [LOAD-URL] Direct loadfile command failed; falling back to helper ' )
2026-02-03 17:14:11 -08:00
end
-- Complex streams (YouTube, DASH, etc.) need the pipeline helper.
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] URL requires pipeline helper for processing ' )
2025-12-18 22:50:21 -08:00
ensure_mpv_ipc_server ( )
2026-02-03 17:14:11 -08:00
local helper_ready = ensure_pipeline_helper_running ( )
2026-02-03 18:46:13 -08:00
_lua_log ( ' [LOAD-URL] Pipeline helper ready: ' .. tostring ( helper_ready ) )
2026-02-03 17:14:11 -08:00
2026-03-16 04:43:46 -07:00
local function start_pipeline_load ( )
-- Use pipeline to download/prepare the URL
local pipeline_cmd = ' .mpv -url ' .. quote_pipeline_arg ( url ) .. ' -play '
_lua_log ( ' [LOAD-URL] Sending to pipeline: ' .. pipeline_cmd )
_lua_log ( ' [LOAD-URL] Pipeline helper ready: ' .. tostring ( _is_pipeline_helper_ready ( ) ) )
local timeout_timer = nil
timeout_timer = mp.add_timeout ( 5 , function ( )
if timeout_timer then
mp.osd_message ( ' Still loading... (helper may be resolving URL) ' , 2 )
_log_all ( ' WARN ' , ' Load URL still processing after 5 seconds ' )
_lua_log ( ' [LOAD-URL] Timeout message shown (helper still processing) ' )
end
end )
M.run_pipeline ( pipeline_cmd , nil , function ( resp , err )
if timeout_timer then
timeout_timer : kill ( )
timeout_timer = nil
end
_lua_log ( ' [LOAD-URL] Pipeline callback received: resp= ' .. tostring ( resp ) .. ' , err= ' .. tostring ( err ) )
if err then
_lua_log ( ' [LOAD-URL] Pipeline error: ' .. tostring ( err ) )
_log_all ( ' ERROR ' , ' Load URL pipeline failed: ' .. tostring ( err ) )
mp.osd_message ( ' Load URL failed: ' .. tostring ( err ) , 3 )
return
end
_lua_log ( ' [LOAD-URL] URL loaded successfully ' )
_log_all ( ' INFO ' , ' Load URL succeeded ' )
mp.osd_message ( ' URL loaded ' , 2 )
if _is_ytdlp_url ( url ) then
_lua_log ( ' [LOAD-URL] URL is yt-dlp compatible, prefetching formats in background ' )
mp.add_timeout ( 0.5 , function ( )
_prefetch_formats_for_url ( url )
end )
end
end )
end
2026-02-03 17:14:11 -08:00
if not helper_ready then
2026-02-04 16:59:04 -08:00
_lua_log ( ' [LOAD-URL] Pipeline helper not available, attempting to start... ' )
_log_all ( ' WARN ' , ' Pipeline helper not running, attempting auto-start ' )
mp.osd_message ( ' Starting pipeline helper... ' , 2 )
-- Attempt to start the helper asynchronously
attempt_start_pipeline_helper_async ( function ( success )
if success then
2026-03-16 04:43:46 -07:00
_lua_log ( ' [LOAD-URL] Helper started successfully, continuing load ' )
2026-02-04 16:59:04 -08:00
_log_all ( ' INFO ' , ' Pipeline helper started successfully ' )
2026-03-16 04:43:46 -07:00
start_pipeline_load ( )
2026-02-04 16:59:04 -08:00
else
_lua_log ( ' [LOAD-URL] Failed to start helper ' )
_log_all ( ' ERROR ' , ' Failed to start pipeline helper ' )
mp.osd_message ( ' Could not start pipeline helper ' , 3 )
end
end )
2026-02-03 17:14:11 -08:00
return
end
2026-03-16 04:43:46 -07:00
start_pipeline_load ( )
2025-12-17 17:42:46 -08:00
end )
2025-11-27 10:59:01 -08:00
-- Menu integration with UOSC
function M . show_menu ( )
2026-02-03 18:26:36 -08:00
_lua_log ( ' [MENU] M.show_menu called ' )
2026-03-22 22:41:56 -07:00
M._reset_uosc_input_state ( ' main-menu ' )
2026-02-03 18:26:36 -08:00
2026-02-06 23:34:20 -08:00
local target = _current_target ( )
2026-03-18 01:26:55 -07:00
local selected_store = trim ( tostring ( _get_selected_store ( ) or ' ' ) )
local download_hint = nil
if selected_store ~= ' ' and target and not _extract_store_hash ( tostring ( target ) ) then
download_hint = _store_status_hint_for_url ( selected_store , tostring ( target ) , ' save to ' .. selected_store )
end
2026-02-06 23:34:20 -08:00
_lua_log ( ' [MENU] current target: ' .. tostring ( target ) )
2026-02-03 18:26:36 -08:00
-- Build menu items
2026-02-03 18:43:54 -08:00
-- Note: UOSC expects command strings, not arrays
2026-02-03 17:14:11 -08:00
local items = {
2026-02-03 18:43:54 -08:00
{ title = " Load URL " , value = " script-message medios-load-url " } ,
2026-02-03 17:14:11 -08:00
{ title = " Get Metadata " , value = " script-binding medios-info " , hint = " Ctrl+i " } ,
{ title = " Delete File " , value = " script-binding medios-delete " , hint = " Ctrl+Del " } ,
2026-02-03 18:43:54 -08:00
{ title = " Cmd " , value = " script-message medios-open-cmd " , hint = " screenshot/trim/etc " } ,
2026-03-18 01:26:55 -07:00
{ title = " Download " , value = " script-message medios-download-current " , hint = download_hint } ,
2026-02-03 17:14:11 -08:00
}
2026-02-06 23:34:20 -08:00
if _is_ytdlp_url ( target ) then
table.insert ( items , { title = " Change Format " , value = " script-message medios-change-format-current " } )
2026-02-03 17:14:11 -08:00
end
2026-02-03 18:26:36 -08:00
_lua_log ( ' [MENU] Built ' .. # items .. ' menu items ' )
-- Check UOSC availability
local uosc_ready = ensure_uosc_loaded ( )
_lua_log ( ' [MENU] ensure_uosc_loaded returned: ' .. tostring ( uosc_ready ) )
if not uosc_ready then
_lua_log ( ' [MENU] ERROR: uosc not available; menu cannot open ' )
mp.osd_message ( ' Menu unavailable (uosc not loaded) ' , 3 )
return
end
-- Format menu for UOSC
2025-11-27 10:59:01 -08:00
local menu_data = {
title = " Medios Macina " ,
2026-02-03 17:14:11 -08:00
items = items ,
2025-11-27 10:59:01 -08:00
}
2026-02-03 17:14:11 -08:00
2025-11-27 10:59:01 -08:00
local json = utils.format_json ( menu_data )
2026-02-03 18:26:36 -08:00
_lua_log ( ' [MENU] Sending menu JSON to uosc: ' .. string.sub ( json , 1 , 200 ) .. ' ... ' )
2026-03-22 22:41:56 -07:00
if M._open_uosc_menu ( menu_data , ' main-menu ' ) then
2026-02-03 18:26:36 -08:00
_lua_log ( ' [MENU] Menu command sent successfully ' )
2026-03-22 22:41:56 -07:00
else
_lua_log ( ' [MENU] Failed to send menu command ' )
mp.osd_message ( ' Menu error ' , 3 )
2025-12-18 22:50:21 -08:00
end
2025-11-27 10:59:01 -08:00
end
2026-02-03 18:26:36 -08:00
-- Keybindings with logging wrappers
mp.add_key_binding ( " m " , " medios-menu " , function ( )
2026-02-03 18:29:29 -08:00
_lua_log ( ' [KEY] m pressed ' )
2026-02-03 18:26:36 -08:00
M.show_menu ( )
end )
2026-02-03 18:35:36 -08:00
mp.add_key_binding ( " z " , " medios-menu-alt " , function ( )
_lua_log ( ' [KEY] z pressed (alternative menu trigger) ' )
M.show_menu ( )
end )
2026-02-03 18:29:29 -08:00
-- NOTE: mbtn_right is claimed by UOSC globally, so we can't override it here.
-- Instead, use script-message handler below for alternative routing.
2025-11-27 10:59:01 -08:00
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 )
2026-02-03 18:29:29 -08:00
-- Script message handler for input.conf routing (right-click via input.conf)
mp.register_script_message ( ' medios-show-menu ' , function ( )
_lua_log ( ' [input.conf] medios-show-menu called ' )
M.show_menu ( )
end )
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 )
2026-02-06 23:34:20 -08:00
2026-03-15 01:51:10 -07:00
local ok_helper , helper_err = pcall ( function ( )
attempt_start_pipeline_helper_async ( function ( success )
if success then
_lua_log ( ' helper-auto-start succeeded ' )
else
_lua_log ( ' helper-auto-start failed ' )
end
end )
2026-02-06 23:34:20 -08:00
end )
2026-03-15 01:51:10 -07:00
if not ok_helper then
_lua_log ( ' helper-auto-start raised: ' .. tostring ( helper_err ) )
end
2026-03-22 22:41:56 -07:00
local ok_lyric , lyric_err = pcall ( function ( )
M._attempt_start_lyric_helper_async ( ' startup ' )
end )
if not ok_lyric then
_lua_log ( ' lyric-helper auto-start raised: ' .. tostring ( lyric_err ) )
end
2026-02-03 18:36:06 -08:00
2026-03-23 21:47:25 -07:00
-- Force-claim mbtn_right so the Medios menu fires reliably regardless of
-- whether uosc has a cursor zone active at the click position.
-- mp.add_forced_key_binding has higher priority than uosc's force-group.
-- Use complex=true to get the event type and fire only on button release.
local ok_rclick , rclick_err = pcall ( function ( )
mp.add_forced_key_binding ( ' mbtn_right ' , ' medios-mbtn-right-forced ' , function ( e )
if e and e.event == ' up ' then
_lua_log ( ' [KEY] mbtn_right up -> show_menu ' )
2026-02-03 18:36:06 -08:00
M.show_menu ( )
2026-03-23 21:47:25 -07:00
end
end , { complex = true , repeatable = false } )
2026-02-03 18:36:06 -08:00
end )
2026-03-23 21:47:25 -07:00
if ok_rclick then
_lua_log ( ' [KEY] registered forced mbtn_right binding ' )
else
_lua_log ( ' [KEY] forced mbtn_right failed: ' .. tostring ( rclick_err ) .. ' (falling back to input.conf) ' )
end
2025-12-17 17:42:46 -08:00
end )
2025-11-27 10:59:01 -08:00
return M