This commit is contained in:
2026-02-06 23:34:20 -08:00
parent d806ebad85
commit af54acda3c
5 changed files with 498 additions and 392 deletions

View File

@@ -22,7 +22,7 @@
"((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)" "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)"
], ],
"regexp": "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)", "regexp": "((1fichier\\.com|megadl\\.fr|alterupload\\.com|cjoint\\.net|desfichiers\\.com|dfichiers\\.com|mesfichiers\\.org|piecejointe\\.net|pjointe\\.com|tenvoi\\.com|dl4free\\.com)/\\?[a-zA-Z0-9]{5,30}(&pw=[^&]+)?)",
"status": true "status": false
}, },
"rapidgator": { "rapidgator": {
"name": "rapidgator", "name": "rapidgator",
@@ -92,7 +92,7 @@
"(hitfile\\.net/[a-z0-9A-Z]{4,9})" "(hitfile\\.net/[a-z0-9A-Z]{4,9})"
], ],
"regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))", "regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))",
"status": false "status": true
}, },
"mega": { "mega": {
"name": "mega", "name": "mega",
@@ -495,7 +495,7 @@
"(katfile\\.com/[0-9a-zA-Z]{12})" "(katfile\\.com/[0-9a-zA-Z]{12})"
], ],
"regexp": "(katfile\\.(cloud|online)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", "regexp": "(katfile\\.(cloud|online)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"status": true "status": false
}, },
"mediafire": { "mediafire": {
"name": "mediafire", "name": "mediafire",

View File

@@ -51,34 +51,16 @@ local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request'
local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response' local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response'
local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready' local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready'
-- Dedicated Lua log (next to mpv log-file) because mp.msg output is not always -- Dedicated Lua log: write directly to logs.db database for unified logging
-- included in --log-file depending on msg-level and build. -- Fallback to stderr if database unavailable
local function _lua_log(text) local function _lua_log(text)
local payload = (text and tostring(text) or '') local payload = (text and tostring(text) or '')
if payload == '' then if payload == '' then
return return
end end
local dir = ''
-- Prefer a stable repo-root Log/ folder based on the script directory. -- Attempt to find repo root for database access
do local repo_root = ''
local function _dirname(p)
p = tostring(p or '')
p = p:gsub('[/\\]+$', '')
return p:match('(.*)[/\\]') or ''
end
local base = mp.get_script_directory() or ''
if base ~= '' then
-- base is expected to be <repo>/MPV/LUA
local root = _dirname(_dirname(base))
if root ~= '' then
dir = utils.join_path(root, 'Log')
end
end
end
-- Prefer repo-root Log/ for consistency with Python helper logs.
do do
local function find_up(start_dir, relative_path, max_levels) local function find_up(start_dir, relative_path, max_levels)
local d = start_dir local d = start_dir
@@ -102,110 +84,33 @@ local function _lua_log(text)
local base = mp.get_script_directory() or utils.getcwd() or '' local base = mp.get_script_directory() or utils.getcwd() or ''
if base ~= '' then if base ~= '' then
local cli = find_up(base, 'CLI.py', 8) local cli = find_up(base, 'CLI.py', 8)
if cli and cli ~= '' then
local root = cli:match('(.*)[/\\]') or ''
if root ~= '' then
dir = utils.join_path(root, 'Log')
end
end
end
end
-- Fallback: next to mpv --log-file.
if dir == '' then
local log_file = mp.get_property('options/log-file') or ''
dir = log_file:match('(.*)[/\\]') or ''
end
if dir == '' then
dir = mp.get_script_directory() or utils.getcwd() or ''
end
if dir == '' then
return
end
local path = utils.join_path(dir, 'medeia-mpv-lua.log')
local fh = io.open(path, 'a')
if not fh then
return
end
local line = '[' .. os.date('%Y-%m-%d %H:%M:%S') .. '] ' .. payload
fh:write(line .. '\n')
fh:close()
-- Also mirror Lua-side debug into the Python helper log file so there's one
-- place to look when diagnosing mpv↔python IPC issues.
do
local helper_path = utils.join_path(dir, 'medeia-mpv-helper.log')
local fh2 = io.open(helper_path, 'a')
if fh2 then
fh2:write('[lua] ' .. line .. '\n')
fh2:close()
end
end
end
_lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))
-- Log to database (logs.db) for centralized error/message tracking
-- This ensures all OSD messages and errors are persisted for debugging
local function _log_to_db(level, message)
message = tostring(message or ''):gsub('"', '\\"')
level = tostring(level or 'INFO'):upper()
-- Find the repo root by looking for CLI.py upwards from script directory
local repo_root = ''
do
local script_dir = mp.get_script_directory() or utils.getcwd() or ''
if script_dir ~= '' then
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 cli = find_up(script_dir, 'CLI.py', 8)
if cli and cli ~= '' then if cli and cli ~= '' then
repo_root = cli:match('(.*)[/\\]') or '' repo_root = cli:match('(.*)[/\\]') or ''
end end
end end
end end
if repo_root == '' then -- Write to logs.db via Python subprocess (non-blocking, async)
return -- Can't find repo root, skip logging to database 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("'", "\\'")
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()
mp.command_native_async({ name = 'subprocess', args = { python, '-c', script }, cwd = nil }, function() end)
end)
end end
-- Escape paths for Python subprocess
repo_root = repo_root:gsub('\\', '/')
-- Use Python to write to the database since Lua can't easily access sqlite3
-- We use a subprocess call with minimal Python to insert into logs.db
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
local db_path = repo_root .. '/logs.db'
local script = string.format(
"import sqlite3,os;p='%s';c=sqlite3.connect(p) if os.path.exists(p) else None;c and (c.execute('INSERT INTO logs (level,module,message) VALUES (?,?,?)',('%s','mpv','%s')),c.commit(),c.close())",
db_path:gsub('\\', '/'),
level,
message
)
pcall(function()
mp.command_native_async({ name = 'subprocess', args = { python, '-c', script }, cwd = nil }, function() end)
end)
end end
-- Combined log: to file + database (for persistence and debugging) _lua_log('medeia lua loaded version=' .. tostring(MEDEIA_LUA_VERSION) .. ' script=' .. tostring(mp.get_script_name()))
-- Combined log: to database (primary) + _lua_log (which also writes to db)
local function _log_all(level, text) local function _log_all(level, text)
if not text or text == '' then if not text or text == '' then
return return
@@ -213,11 +118,8 @@ local function _log_all(level, text)
level = tostring(level or 'INFO'):upper() level = tostring(level or 'INFO'):upper()
text = tostring(text) text = tostring(text)
-- Log to file -- Log with level prefix via _lua_log (which writes to database)
_lua_log('[' .. level .. '] ' .. text) _lua_log('[' .. level .. '] ' .. text)
-- Log to database (async, non-blocking)
_log_to_db(level, text)
end end
local function ensure_uosc_loaded() local function ensure_uosc_loaded()
@@ -344,8 +246,8 @@ end
-- Default to visible unless user overrides. -- Default to visible unless user overrides.
lyric_set_visible(true) lyric_set_visible(true)
-- Configuration -- Configuration (global so _lua_log can see python_path early)
local opts = { opts = {
python_path = "python", python_path = "python",
cli_path = nil -- Will be auto-detected if nil cli_path = nil -- Will be auto-detected if nil
} }
@@ -375,6 +277,9 @@ local function find_file_upwards(start_dir, relative_path, max_levels)
return nil return nil
end end
-- Forward declaration (defined later) used by helper auto-start.
local _resolve_python_exe
local _cached_store_names = {} local _cached_store_names = {}
local _store_cache_loaded = false local _store_cache_loaded = false
@@ -482,35 +387,55 @@ local _last_ipc_last_resp_json = ''
local _helper_start_debounce_ts = 0 local _helper_start_debounce_ts = 0
local HELPER_START_DEBOUNCE = 2.0 local HELPER_START_DEBOUNCE = 2.0
-- 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
local function _is_pipeline_helper_ready() local function _is_pipeline_helper_ready()
local ready = mp.get_property(PIPELINE_READY_PROP) local ready = mp.get_property(PIPELINE_READY_PROP)
if ready == nil or ready == '' then if ready == nil or ready == '' then
ready = mp.get_property_native(PIPELINE_READY_PROP) ready = mp.get_property_native(PIPELINE_READY_PROP)
end end
if not ready then if not ready then
_helper_ready_last_value = ''
_helper_ready_last_seen_ts = 0
return false return false
end end
local s = tostring(ready) local s = tostring(ready)
if s == '' or s == '0' then if s == '' or s == '0' then
_helper_ready_last_value = s
_helper_ready_last_seen_ts = 0
return false return false
end end
-- Only support unix timestamp heartbeats from current helper version local now = mp.get_time() or 0
local n = tonumber(s) if s ~= _helper_ready_last_value then
if n and n > 1000000000 then _helper_ready_last_value = s
local now = (os and os.time) and os.time() or nil _helper_ready_last_seen_ts = now
if not now then
return true
end
local age = now - n
if age < 0 then
age = 0
end
return age <= 10
end end
-- Non-empty value treated as ready -- Prefer timestamp heartbeats from modern helpers.
return true local n = tonumber(s)
if n and n > 1000000000 then
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
end
end
-- Fall back to the last time we observed a new value so stale data does not appear fresh.
if _helper_ready_last_seen_ts > 0 and (now - _helper_ready_last_seen_ts) <= HELPER_READY_STALE_SECONDS then
return true
end
return false
end end
local function get_mpv_ipc_path() local function get_mpv_ipc_path()
@@ -630,6 +555,29 @@ local _ipc_async_queue = {}
local function _run_helper_request_async(req, timeout_seconds, cb) local function _run_helper_request_async(req, timeout_seconds, cb)
cb = cb or function() end cb = cb or function() end
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) .. ')')
if _ipc_async_busy then if _ipc_async_busy then
_ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb } _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb }
return return
@@ -637,6 +585,36 @@ local function _run_helper_request_async(req, timeout_seconds, cb)
_ipc_async_busy = true _ipc_async_busy = true
local function done(resp, err) local function done(resp, err)
local err_text = err and tostring(err) or ''
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
local is_retryable = is_timeout and type(req) == 'table'
and tostring(req.op or '') == 'ytdlp-formats' and retry_count < 1
if is_retryable then
req._retry = retry_count + 1
req.id = nil
_ipc_async_busy = false
_lua_log('ipc-async: timeout on ytdlp-formats; restarting helper and retrying (attempt ' .. tostring(req._retry) .. ')')
pcall(mp.set_property, PIPELINE_READY_PROP, '')
attempt_start_pipeline_helper_async(function(success)
if success then
_lua_log('ipc-async: helper restart succeeded; retrying ytdlp-formats')
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
_lua_log('ipc-async: done id=' .. tostring(id) .. ' ERROR: ' .. tostring(err))
else
_lua_log('ipc-async: done id=' .. tostring(id) .. ' success=' .. tostring(resp and resp.success))
end
_ipc_async_busy = false _ipc_async_busy = false
cb(resp, err) cb(resp, err)
@@ -655,71 +633,86 @@ local function _run_helper_request_async(req, timeout_seconds, cb)
end end
ensure_mpv_ipc_server() ensure_mpv_ipc_server()
if not ensure_pipeline_helper_running() then
done(nil, 'helper not running')
return
end
-- Assign id. local function send_request_payload()
local id = tostring(req.id or '') _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label)
if id == '' then local req_json = utils.format_json(req)
id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) _last_ipc_last_req_json = req_json
req.id = id
end
local label = '' mp.set_property(PIPELINE_RESP_PROP, '')
if req.op then mp.set_property(PIPELINE_REQ_PROP, req_json)
label = 'op=' .. tostring(req.op)
elseif req.pipeline then
label = 'cmd=' .. tostring(req.pipeline)
else
label = '(unknown)'
end
-- Wait for helper READY without blocking the UI. local deadline = mp.get_time() + (timeout_seconds or 5)
local ready_deadline = mp.get_time() + 3.0 local poll_timer
local ready_timer poll_timer = mp.add_periodic_timer(0.05, function()
ready_timer = mp.add_periodic_timer(0.05, function() if mp.get_time() >= deadline then
if _is_pipeline_helper_ready() then poll_timer:kill()
ready_timer:kill() done(nil, 'timeout waiting response (' .. label .. ')')
return
end
_lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label) local resp_json = mp.get_property(PIPELINE_RESP_PROP)
local req_json = utils.format_json(req) if resp_json and resp_json ~= '' then
_last_ipc_last_req_json = req_json _last_ipc_last_resp_json = resp_json
local ok, resp = pcall(utils.parse_json, resp_json)
mp.set_property(PIPELINE_RESP_PROP, '') if ok and resp and resp.id == id then
mp.set_property(PIPELINE_REQ_PROP, req_json)
local deadline = mp.get_time() + (timeout_seconds or 5)
local poll_timer
poll_timer = mp.add_periodic_timer(0.05, function()
if mp.get_time() >= deadline then
poll_timer:kill() poll_timer:kill()
done(nil, 'timeout waiting response (' .. label .. ')') _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
return done(resp, nil)
end end
end
end)
end
local resp_json = mp.get_property(PIPELINE_RESP_PROP) local function wait_for_helper_ready(timeout, on_ready)
if resp_json and resp_json ~= '' then local deadline = mp.get_time() + (timeout or 3.0)
_last_ipc_last_resp_json = resp_json local ready_timer
local ok, resp = pcall(utils.parse_json, resp_json) ready_timer = mp.add_periodic_timer(0.05, function()
if ok and resp and resp.id == id then if _is_pipeline_helper_ready() then
poll_timer:kill() ready_timer:kill()
_lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) on_ready()
done(resp, nil) return
end end
end if mp.get_time() >= deadline then
end) ready_timer:kill()
done(nil, 'helper not ready')
return
end
end)
end
local function ensure_helper_and_send()
if _is_pipeline_helper_ready() then
wait_for_helper_ready(3.0, send_request_payload)
return return
end end
if mp.get_time() >= ready_deadline then _lua_log('ipc-async: helper not ready, auto-starting before request id=' .. id)
ready_timer:kill() attempt_start_pipeline_helper_async(function(success)
done(nil, 'helper not ready') if not success then
return _lua_log('ipc-async: helper auto-start failed while handling request id=' .. id)
end else
end) _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()
done(nil, 'helper not running')
return
end
end)
end
ensure_helper_and_send()
end end
local function _run_helper_request_response(req, timeout_seconds) local function _run_helper_request_response(req, timeout_seconds)
@@ -830,13 +823,14 @@ local function _url_can_direct_load(url)
return true return true
end end
local function _try_direct_loadfile(url) local function _try_direct_loadfile(url, force)
-- Attempt to load URL directly via mpv without pipeline. -- Attempt to load URL directly via mpv without pipeline.
-- Returns (success: bool, loaded: bool) where: -- Returns (success: bool, loaded: bool) where:
-- - success=true, loaded=true: URL loaded successfully -- - success=true, loaded=true: URL loaded successfully
-- - success=true, loaded=false: URL not suitable for direct load -- - success=true, loaded=false: URL not suitable for direct load (when not forced)
-- - success=false: loadfile command failed -- - success=false: loadfile command failed
if not _url_can_direct_load(url) then force = force and true or false
if not force and not _url_can_direct_load(url) then
_lua_log('_try_direct_loadfile: URL not suitable for direct load: ' .. url) _lua_log('_try_direct_loadfile: URL not suitable for direct load: ' .. url)
return true, false -- Not suitable, but not an error return true, false -- Not suitable, but not an error
end end
@@ -858,7 +852,7 @@ local function _is_windows()
return sep == '\\' return sep == '\\'
end end
local function _resolve_python_exe(prefer_no_console) _resolve_python_exe = function(prefer_no_console)
local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python' local python = (opts and opts.python_path) and tostring(opts.python_path) or 'python'
if (not prefer_no_console) or (not _is_windows()) then if (not prefer_no_console) or (not _is_windows()) then
return python return python
@@ -1711,6 +1705,75 @@ local function _is_http_url(u)
return u:match('^https?://') ~= nil return u:match('^https?://') ~= nil
end end
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
local function _cache_formats_for_url(url, tbl) local function _cache_formats_for_url(url, tbl)
if type(url) ~= 'string' or url == '' then if type(url) ~= 'string' or url == '' then
return return
@@ -1741,59 +1804,97 @@ end
function FileState:fetch_formats(cb) function FileState:fetch_formats(cb)
local url = tostring(self.url or '') local url = tostring(self.url or '')
_lua_log('fetch-formats: started for url=' .. url)
if url == '' or not _is_http_url(url) then if url == '' or not _is_http_url(url) then
_lua_log('fetch-formats: skipped (not a url)')
if cb then cb(false, 'not a url') end if cb then cb(false, 'not a url') end
return return
end end
if _extract_store_hash(url) then if _extract_store_hash(url) then
_lua_log('fetch-formats: skipped (store-hash)')
if cb then cb(false, 'store-hash url') end if cb then cb(false, 'store-hash url') end
return return
end end
local cached = _get_cached_formats_table(url) local cached = _get_cached_formats_table(url)
if type(cached) == 'table' then if type(cached) == 'table' then
_lua_log('fetch-formats: using cached table')
self:set_formats(url, cached) self:set_formats(url, cached)
if cb then cb(true, nil) end if cb then cb(true, nil) end
return return
end end
if _formats_inflight[url] then 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
_lua_log('fetch-formats: initiating IPC request')
_formats_inflight[url] = true
_formats_waiters[url] = _formats_waiters[url] or {} _formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end if cb then table.insert(_formats_waiters[url], cb) end
return
end
_formats_inflight[url] = true
_formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end
_run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err) _run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err)
_formats_inflight[url] = nil _lua_log('fetch-formats: IPC callback received id=' .. tostring(resp and resp.id) .. ' err=' .. tostring(err))
_formats_inflight[url] = nil
local ok = false local ok = false
local reason = err local reason = err
if resp and resp.success and type(resp.table) == 'table' then if resp and resp.success and type(resp.table) == 'table' then
ok = true ok = true
reason = nil reason = nil
self:set_formats(url, resp.table) self:set_formats(url, resp.table)
_cache_formats_for_url(url, resp.table) _cache_formats_for_url(url, resp.table)
_lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url') _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
else else
if type(resp) == 'table' then _lua_log('fetch-formats: request failed success=' .. tostring(resp and resp.success))
if resp.error and tostring(resp.error) ~= '' then if type(resp) == 'table' then
reason = tostring(resp.error) if resp.error and tostring(resp.error) ~= '' then
elseif resp.stderr and tostring(resp.stderr) ~= '' then reason = tostring(resp.error)
reason = tostring(resp.stderr) elseif resp.stderr and tostring(resp.stderr) ~= '' then
reason = tostring(resp.stderr)
end
end end
end end
end
local waiters = _formats_waiters[url] or {} local waiters = _formats_waiters[url] or {}
_formats_waiters[url] = nil _lua_log('fetch-formats: calling ' .. tostring(#waiters) .. ' waiters with ok=' .. tostring(ok) .. ' reason=' .. tostring(reason))
for _, fn in ipairs(waiters) do _formats_waiters[url] = nil
pcall(fn, ok, reason) for _, fn in ipairs(waiters) do
end pcall(fn, ok, reason)
end) end
end)
end
if not ensure_pipeline_helper_running() then
_lua_log('fetch-formats: helper not running, waiting before requesting')
local deadline = mp.get_time() + 6.0
local waiter
waiter = mp.add_periodic_timer(0.1, function()
if _is_pipeline_helper_ready() then
waiter:kill()
_lua_log('fetch-formats: helper became ready, continuing request')
_perform_request()
return
end
if mp.get_time() >= deadline then
waiter:kill()
_lua_log('fetch-formats: helper still not ready after timeout')
if cb then cb(false, 'helper not running') end
end
end)
attempt_start_pipeline_helper_async(function(success)
if not success then
_lua_log('fetch-formats: helper auto-start failed')
end
end)
return
end
_perform_request()
end end
local function _prefetch_formats_for_url(url) local function _prefetch_formats_for_url(url)
@@ -1847,6 +1948,38 @@ local function _debug_dump_formatted_formats(url, tbl, items)
end end
end end
local function _show_format_list_osd(items, max_items)
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
local function _current_ytdl_format_string() local function _current_ytdl_format_string()
-- Preferred: mpv exposes the active ytdl format string. -- Preferred: mpv exposes the active ytdl format string.
local fmt = trim(tostring(mp.get_property_native('ytdl-format') or '')) local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
@@ -2087,6 +2220,7 @@ mp.register_script_message('medios-change-format-current', function()
end end
_debug_dump_formatted_formats(url, cached_tbl, items) _debug_dump_formatted_formats(url, cached_tbl, items)
_show_format_list_osd(items, 8)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
return return
end end
@@ -2155,6 +2289,7 @@ mp.register_script_message('medios-change-format-current', function()
_pending_format_change.formats_table = tbl _pending_format_change.formats_table = tbl
_debug_dump_formatted_formats(url, tbl, items) _debug_dump_formatted_formats(url, tbl, items)
_show_format_list_osd(items, 8)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items) _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
end) end)
end end
@@ -2392,9 +2527,10 @@ function M.run_pipeline(pipeline_cmd, seeds, cb)
end end
ensure_mpv_ipc_server() ensure_mpv_ipc_server()
-- Use shorter timeout for .mpv -url commands since they just queue the URL (non-blocking) -- Use a longer timeout for `.mpv -url` commands to avoid races with slow helper starts.
-- The actual URL resolution happens asynchronously in MPV itself local lower_cmd = pipeline_cmd:lower()
local timeout_seconds = pipeline_cmd:match('%.mpv%s+%-url') and 10 or 30 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) _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)) _lua_log('M.run_pipeline callback fired: resp=' .. tostring(resp) .. ', err=' .. tostring(err))
if resp and resp.success then if resp and resp.success then
@@ -2879,22 +3015,18 @@ mp.register_script_message('medios-load-url-event', function(json)
local can_direct = _url_can_direct_load(url) local can_direct = _url_can_direct_load(url)
_lua_log('[LOAD-URL] Checking if URL can be loaded directly: ' .. tostring(can_direct)) _lua_log('[LOAD-URL] Checking if URL can be loaded directly: ' .. tostring(can_direct))
if can_direct then local direct_ok, direct_loaded = _try_direct_loadfile(url, false)
_lua_log('[LOAD-URL] Attempting direct loadfile') if direct_ok and direct_loaded then
local ok_load = pcall(mp.commandv, 'loadfile', url, 'replace') _lua_log('[LOAD-URL] Direct loadfile command sent successfully (forced)')
if ok_load then _log_all('INFO', 'Load URL succeeded via direct load')
_lua_log('[LOAD-URL] Direct loadfile command sent successfully') mp.osd_message('URL loaded', 2)
_log_all('INFO', 'Load URL succeeded via direct load') close_menu()
mp.osd_message('URL loaded', 2) return
close_menu() end
return if direct_ok then
else _lua_log('[LOAD-URL] Direct loadfile command did not load the URL; falling back to helper')
_lua_log('[LOAD-URL] Direct loadfile command failed') else
_log_all('ERROR', 'Load URL failed: direct loadfile command failed') _lua_log('[LOAD-URL] Direct loadfile command failed; falling back to helper')
mp.osd_message('Load URL failed (direct)', 3)
close_menu()
return
end
end end
-- Complex streams (YouTube, DASH, etc.) need the pipeline helper. -- Complex streams (YouTube, DASH, etc.) need the pipeline helper.
@@ -2956,6 +3088,15 @@ mp.register_script_message('medios-load-url-event', function(json)
_lua_log('[LOAD-URL] URL loaded successfully') _lua_log('[LOAD-URL] URL loaded successfully')
_log_all('INFO', 'Load URL succeeded') _log_all('INFO', 'Load URL succeeded')
mp.osd_message('URL loaded', 2) mp.osd_message('URL loaded', 2)
-- Prefetch formats for yt-dlp URLs so "Change Format" menu is instant
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
close_menu() close_menu()
end) end)
end) end)
@@ -2964,6 +3105,9 @@ end)
function M.show_menu() function M.show_menu()
_lua_log('[MENU] M.show_menu called') _lua_log('[MENU] M.show_menu called')
local target = _current_target()
_lua_log('[MENU] current target: ' .. tostring(target))
-- Build menu items -- Build menu items
-- Note: UOSC expects command strings, not arrays -- Note: UOSC expects command strings, not arrays
local items = { local items = {
@@ -2972,12 +3116,10 @@ function M.show_menu()
{ title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" }, { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" },
{ title = "Cmd", value = "script-message medios-open-cmd", hint = "screenshot/trim/etc" }, { title = "Cmd", value = "script-message medios-open-cmd", hint = "screenshot/trim/etc" },
{ title = "Download", value = "script-message medios-download-current" }, { title = "Download", value = "script-message medios-download-current" },
{ title = "Change Format", value = "script-message medios-change-format-current" },
} }
-- Conditionally add "Start Helper" if not running if _is_ytdlp_url(target) then
if not _is_pipeline_helper_ready() then table.insert(items, { title = "Change Format", value = "script-message medios-change-format-current" })
table.insert(items, { title = "Start Helper", hint = "(pipeline actions)", value = "script-message medios-start-helper" })
end end
_lua_log('[MENU] Built ' .. #items .. ' menu items') _lua_log('[MENU] Built ' .. #items .. ' menu items')
@@ -3058,6 +3200,14 @@ mp.add_timeout(0, function()
pcall(ensure_mpv_ipc_server) pcall(ensure_mpv_ipc_server)
pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION) pcall(_lua_log, 'medeia-lua loaded version=' .. MEDEIA_LUA_VERSION)
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)
-- Try to re-register right-click after UOSC loads (might override its binding) -- Try to re-register right-click after UOSC loads (might override its binding)
mp.add_timeout(1.0, function() mp.add_timeout(1.0, function()
_lua_log('[KEY] attempting to re-register mbtn_right after UOSC loaded') _lua_log('[KEY] attempting to re-register mbtn_right after UOSC loaded')

View File

@@ -376,31 +376,14 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
"table": None, "table": None,
} }
# Fast gate: only for streaming URLs yt-dlp knows about.
try: try:
from tool.ytdlp import is_url_supported_by_ytdlp # noqa: WPS433 from tool.ytdlp import list_formats, is_browseable_format # noqa: WPS433
if not is_url_supported_by_ytdlp(url):
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "URL not supported by yt-dlp",
"table": None,
}
except Exception:
# If probing support fails, still attempt extraction and let yt-dlp decide.
pass
try:
import yt_dlp # type: ignore
except Exception as exc: except Exception as exc:
return { return {
"success": False, "success": False,
"stdout": "", "stdout": "",
"stderr": "", "stderr": "",
"error": "error": f"yt-dlp tool unavailable: {type(exc).__name__}: {exc}",
f"yt-dlp module not available: {type(exc).__name__}: {exc}",
"table": None, "table": None,
} }
@@ -415,96 +398,27 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
except Exception: except Exception:
cookiefile = None cookiefile = None
ydl_opts: Dict[str,
Any] = {
"quiet": True,
"no_warnings": True,
"socket_timeout": 20,
"retries": 2,
"skip_download": True,
# Avoid accidentally expanding huge playlists on load.
"noplaylist": True,
"noprogress": True,
}
if cookiefile:
ydl_opts["cookiefile"] = cookiefile
def _format_bytes(n: Any) -> str: def _format_bytes(n: Any) -> str:
"""Format bytes using centralized utility.""" """Format bytes using centralized utility."""
return format_bytes(n) return format_bytes(n)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined] formats = list_formats(
info = ydl.extract_info(url, download=False) url,
no_playlist=True,
cookiefile=cookiefile,
timeout_seconds=25,
)
# Debug: dump a short summary of the format list to the helper log. if formats is None:
try:
formats_any = info.get("formats") if isinstance(info, dict) else None
count = len(formats_any) if isinstance(formats_any, list) else 0
_append_helper_log(
f"[ytdlp-formats] extracted formats count={count} url={url}"
)
if isinstance(formats_any, list) and formats_any:
limit = 60
for i, f in enumerate(formats_any[:limit], start=1):
if not isinstance(f, dict):
continue
fid = str(f.get("format_id") or "")
ext = str(f.get("ext") or "")
note = f.get("format_note") or f.get("format") or ""
vcodec = str(f.get("vcodec") or "")
acodec = str(f.get("acodec") or "")
size = f.get("filesize") or f.get("filesize_approx")
res = str(f.get("resolution") or "")
if not res:
try:
w = f.get("width")
h = f.get("height")
if w and h:
res = f"{int(w)}x{int(h)}"
elif h:
res = f"{int(h)}p"
except Exception:
res = ""
_append_helper_log(
f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}"
)
if count > limit:
_append_helper_log(
f"[ytdlp-formats] (truncated; total={count})"
)
except Exception:
pass
# Optional: dump the full extracted JSON for inspection.
try:
dump = os.environ.get("MEDEIA_MPV_YTDLP_DUMP", "").strip()
if dump and dump != "0" and isinstance(info, dict):
h = hashlib.sha1(url.encode("utf-8",
errors="replace")).hexdigest()[:10]
out_path = _repo_root() / "Log" / f"ytdlp-probe-{h}.json"
out_path.write_text(
json.dumps(info,
ensure_ascii=False,
indent=2),
encoding="utf-8",
errors="replace",
)
_append_helper_log(f"[ytdlp-formats] wrote probe json: {out_path}")
except Exception:
pass
if not isinstance(info, dict):
return { return {
"success": False, "success": False,
"stdout": "", "stdout": "",
"stderr": "", "stderr": "",
"error": "yt-dlp returned non-dict info", "error": "yt-dlp format probe failed or timed out",
"table": None, "table": None,
} }
formats = info.get("formats") if not formats:
if not isinstance(formats, list) or not formats:
return { return {
"success": True, "success": True,
"stdout": "", "stdout": "",
@@ -516,6 +430,48 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
}, },
} }
browseable = [f for f in formats if is_browseable_format(f)]
if browseable:
formats = browseable
# Debug: dump a short summary of the format list to the helper log.
try:
count = len(formats)
_append_helper_log(
f"[ytdlp-formats] extracted formats count={count} url={url}"
)
limit = 60
for i, f in enumerate(formats[:limit], start=1):
if not isinstance(f, dict):
continue
fid = str(f.get("format_id") or "")
ext = str(f.get("ext") or "")
note = f.get("format_note") or f.get("format") or ""
vcodec = str(f.get("vcodec") or "")
acodec = str(f.get("acodec") or "")
size = f.get("filesize") or f.get("filesize_approx")
res = str(f.get("resolution") or "")
if not res:
try:
w = f.get("width")
h = f.get("height")
if w and h:
res = f"{int(w)}x{int(h)}"
elif h:
res = f"{int(h)}p"
except Exception:
res = ""
_append_helper_log(
f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}"
)
if count > limit:
_append_helper_log(
f"[ytdlp-formats] (truncated; total={count})"
)
except Exception:
pass
rows = [] rows = []
for fmt in formats: for fmt in formats:
if not isinstance(fmt, dict): if not isinstance(fmt, dict):
@@ -540,8 +496,15 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
ext = str(fmt.get("ext") or "").strip() ext = str(fmt.get("ext") or "").strip()
size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx")) size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx"))
vcodec = str(fmt.get("vcodec") or "none")
acodec = str(fmt.get("acodec") or "none")
selection_id = format_id
if vcodec != "none" and acodec == "none":
selection_id = f"{format_id}+ba"
# Build selection args compatible with MPV Lua picker. # Build selection args compatible with MPV Lua picker.
selection_args = ["-query", f"format:{format_id}"] # Use -format instead of -query so Lua can extract the ID easily.
selection_args = ["-format", selection_id]
rows.append( rows.append(
{ {
@@ -962,6 +925,28 @@ def main(argv: Optional[list[str]] = None) -> int:
f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}" f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}"
) )
# Publish yt-dlp supported domains for Lua menu filtering
try:
from tool.ytdlp import _build_supported_domains
domains = sorted(list(_build_supported_domains()))
if domains:
# We join them into a space-separated string for Lua to parse easily
domains_str = " ".join(domains)
client.send_command_no_wait(
[
"set_property_string",
"user-data/medeia-ytdlp-domains-cached",
domains_str
]
)
_append_helper_log(
f"[helper] published {len(domains)} ytdlp domains for Lua menu filtering"
)
except Exception as exc:
_append_helper_log(
f"[helper] failed to publish ytdlp domains: {type(exc).__name__}: {exc}"
)
last_seen_id: Optional[str] = None last_seen_id: Optional[str] = None
try: try:

View File

@@ -4,6 +4,10 @@ osd-bar=no
ytdl=yes ytdl=yes
# uosc will draw its own window controls and border if you disable window border # uosc will draw its own window controls and border if you disable window border
border=no border=no
cache=yes
cache-secs=30
demuxer-max-bytes=200MiB
demuxer-max-back-bytes=100MiB
# Ensure uosc texture/icon fonts are discoverable by libass. # Ensure uosc texture/icon fonts are discoverable by libass.
osd-fonts-dir=~~/scripts/uosc/fonts osd-fonts-dir=~~/scripts/uosc/fonts

View File

@@ -2068,13 +2068,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
"If you need full [main2] logs, restart mpv so it starts with --log-file." "If you need full [main2] logs, restart mpv so it starts with --log-file."
) )
# Print database logs for mpv module (helper output) # Print database logs for mpv module (helper + lua output)
try: try:
import sqlite3 import sqlite3
log_db_path = str((Path(__file__).resolve().parent.parent / "logs.db")) log_db_path = str((Path(__file__).resolve().parent.parent / "logs.db"))
conn = sqlite3.connect(log_db_path, timeout=5.0) conn = sqlite3.connect(log_db_path, timeout=5.0)
cur = conn.cursor() cur = conn.cursor()
query = "SELECT level, module, message FROM logs WHERE module = 'mpv'" query = "SELECT timestamp, level, module, message FROM logs WHERE module = 'mpv'"
params: List[str] = [] params: List[str] = []
if log_filter_text: if log_filter_text:
query += " AND LOWER(message) LIKE ?" query += " AND LOWER(message) LIKE ?"
@@ -2085,57 +2085,24 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
cur.close() cur.close()
conn.close() conn.close()
if log_filter_text: if log_filter_text:
print(f"Helper logs from database (mpv module, filtered by '{log_filter_text}', most recent first):") print(f"MPV logs from database (mpv module, filtered by '{log_filter_text}', most recent first):")
else: else:
print("Helper logs from database (mpv module, most recent first):") print("MPV logs from database (mpv module, most recent first):")
if mpv_logs: if mpv_logs:
for level, module, message in mpv_logs: for timestamp, level, module, message in mpv_logs:
print(f"[{level}] {message}") ts = str(timestamp or "").strip()
if ts:
print(f"[{ts}] [{level}] {message}")
else:
print(f"[{level}] {message}")
else: else:
if log_filter_text: if log_filter_text:
print(f"(no helper logs found matching '{log_filter_text}')") print(f"(no mpv logs found matching '{log_filter_text}')")
else: else:
print("(no helper logs found)") print("(no mpv logs found)")
except Exception as e: except Exception as e:
debug(f"Could not fetch database logs: {e}") debug(f"Could not fetch database logs: {e}")
pass pass
# Also print the helper log tail (this captures Python helper output that won't
# necessarily show up in MPV's own log-file).
try:
helper_path = _helper_log_file()
helper_tail = _tail_text_file(str(helper_path), max_lines=200)
filtered_helper = _apply_log_filter(helper_tail, log_filter_text)
print(f"Helper log file: {str(helper_path)}")
if filtered_helper:
print("Helper log (tail):")
for ln in filtered_helper:
print(ln)
else:
if log_filter_text:
print(f"(no helper file logs found matching '{log_filter_text}')")
else:
print("Helper log (tail): <empty>")
except Exception:
pass
# Also print the Lua-side log tail (mp.msg output isn't always written to mpv's log-file).
try:
lua_path = _lua_log_file()
lua_tail = _tail_text_file(str(lua_path), max_lines=200)
filtered_lua = _apply_log_filter(lua_tail, log_filter_text)
print(f"Lua log file: {str(lua_path)}")
if filtered_lua:
print("Lua log (tail):")
for ln in filtered_lua:
print(ln)
else:
if log_filter_text:
print(f"(no lua file logs found matching '{log_filter_text}')")
else:
print("Lua log (tail): <empty>")
except Exception:
pass
except Exception: except Exception:
pass pass
try: try: