From af54acda3cff2b2e3523bf68802d445721e2c903 Mon Sep 17 00:00:00 2001 From: Nose Date: Fri, 6 Feb 2026 23:34:20 -0800 Subject: [PATCH] j --- API/data/alldebrid.json | 6 +- MPV/LUA/main.lua | 642 +++++++++++++++++++++-------------- MPV/pipeline_helper.py | 181 +++++----- MPV/portable_config/mpv.conf | 4 + cmdnat/pipe.py | 57 +--- 5 files changed, 498 insertions(+), 392 deletions(-) diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 1526c78..c490d08 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -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=[^&]+)?)" ], "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": { "name": "rapidgator", @@ -92,7 +92,7 @@ "(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": { "name": "mega", @@ -495,7 +495,7 @@ "(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": { "name": "mediafire", diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 91302e4..268ee09 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -51,34 +51,16 @@ local PIPELINE_REQ_PROP = 'user-data/medeia-pipeline-request' local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response' local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready' --- Dedicated Lua log (next to mpv log-file) because mp.msg output is not always --- included in --log-file depending on msg-level and build. +-- 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 - local dir = '' - -- Prefer a stable repo-root Log/ folder based on the script directory. - do - local function _dirname(p) - p = tostring(p or '') - p = p:gsub('[/\\]+$', '') - return p:match('(.*)[/\\]') or '' - end - - local base = mp.get_script_directory() or '' - if base ~= '' then - -- base is expected to be /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. + -- Attempt to find repo root for database access + local repo_root = '' do local function find_up(start_dir, relative_path, max_levels) local d = start_dir @@ -103,109 +85,32 @@ local function _lua_log(text) if base ~= '' then local cli = find_up(base, 'CLI.py', 8) if cli and cli ~= '' then - local root = cli:match('(.*)[/\\]') or '' - if root ~= '' then - dir = utils.join_path(root, 'Log') - end + repo_root = cli:match('(.*)[/\\]') or '' 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() + -- 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("'", "\\'") - -- 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 + 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 _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 - repo_root = cli:match('(.*)[/\\]') or '' - end - end - end - - if repo_root == '' then - return -- Can't find repo root, skip logging to database - 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 - --- Combined log: to file + database (for persistence and debugging) +-- Combined log: to database (primary) + _lua_log (which also writes to db) local function _log_all(level, text) if not text or text == '' then return @@ -213,11 +118,8 @@ local function _log_all(level, text) level = tostring(level or 'INFO'):upper() text = tostring(text) - -- Log to file + -- Log with level prefix via _lua_log (which writes to database) _lua_log('[' .. level .. '] ' .. text) - - -- Log to database (async, non-blocking) - _log_to_db(level, text) end local function ensure_uosc_loaded() @@ -344,8 +246,8 @@ end -- Default to visible unless user overrides. lyric_set_visible(true) --- Configuration -local opts = { +-- Configuration (global so _lua_log can see python_path early) +opts = { python_path = "python", 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 end +-- Forward declaration (defined later) used by helper auto-start. +local _resolve_python_exe + local _cached_store_names = {} 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 = 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 ready = mp.get_property(PIPELINE_READY_PROP) if ready == nil or ready == '' then ready = mp.get_property_native(PIPELINE_READY_PROP) end if not ready then + _helper_ready_last_value = '' + _helper_ready_last_seen_ts = 0 return false end local s = tostring(ready) if s == '' or s == '0' then + _helper_ready_last_value = s + _helper_ready_last_seen_ts = 0 return false end - -- Only support unix timestamp heartbeats from current helper version - local n = tonumber(s) - if n and n > 1000000000 then - local now = (os and os.time) and os.time() or nil - if not now then - return true - end - local age = now - n - if age < 0 then - age = 0 - end - return age <= 10 + 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 - -- Non-empty value treated as ready - return true + -- Prefer timestamp heartbeats from modern helpers. + 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 local function get_mpv_ipc_path() @@ -630,6 +555,29 @@ local _ipc_async_queue = {} local function _run_helper_request_async(req, timeout_seconds, cb) 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 _ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb } return @@ -637,6 +585,36 @@ local function _run_helper_request_async(req, timeout_seconds, cb) _ipc_async_busy = true 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 cb(resp, err) @@ -655,71 +633,86 @@ local function _run_helper_request_async(req, timeout_seconds, cb) end ensure_mpv_ipc_server() - if not ensure_pipeline_helper_running() then - done(nil, 'helper not running') - return - end - -- Assign id. - local id = tostring(req.id or '') - if id == '' then - id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999)) - req.id = id - end + local 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 - local label = '' - if req.op then - label = 'op=' .. tostring(req.op) - elseif req.pipeline then - label = 'cmd=' .. tostring(req.pipeline) - else - label = '(unknown)' - end + mp.set_property(PIPELINE_RESP_PROP, '') + mp.set_property(PIPELINE_REQ_PROP, req_json) - -- Wait for helper READY without blocking the UI. - local ready_deadline = mp.get_time() + 3.0 - local ready_timer - ready_timer = mp.add_periodic_timer(0.05, function() - if _is_pipeline_helper_ready() then - ready_timer:kill() + 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 - _lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label) - local req_json = utils.format_json(req) - _last_ipc_last_req_json = req_json - - mp.set_property(PIPELINE_RESP_PROP, '') - mp.set_property(PIPELINE_REQ_PROP, req_json) - - local deadline = mp.get_time() + (timeout_seconds or 5) - local poll_timer - poll_timer = mp.add_periodic_timer(0.05, function() - if mp.get_time() >= deadline then + local resp_json = mp.get_property(PIPELINE_RESP_PROP) + if resp_json and resp_json ~= '' then + _last_ipc_last_resp_json = resp_json + local ok, resp = pcall(utils.parse_json, resp_json) + if ok and resp and resp.id == id then poll_timer:kill() - done(nil, 'timeout waiting response (' .. label .. ')') - return + _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) + done(resp, nil) end + end + end) + end - local resp_json = mp.get_property(PIPELINE_RESP_PROP) - if resp_json and resp_json ~= '' then - _last_ipc_last_resp_json = resp_json - local ok, resp = pcall(utils.parse_json, resp_json) - if ok and resp and resp.id == id then - poll_timer:kill() - _lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success)) - done(resp, nil) - end - end - end) + 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() + 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 end - if mp.get_time() >= ready_deadline then - ready_timer:kill() - done(nil, 'helper not ready') - return - end - end) + _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() + done(nil, 'helper not running') + return + end + end) + end + + ensure_helper_and_send() end local function _run_helper_request_response(req, timeout_seconds) @@ -830,13 +823,14 @@ local function _url_can_direct_load(url) return true end -local function _try_direct_loadfile(url) +local function _try_direct_loadfile(url, force) -- Attempt to load URL directly via mpv without pipeline. -- Returns (success: bool, loaded: bool) where: -- - 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 - 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) return true, false -- Not suitable, but not an error end @@ -858,7 +852,7 @@ local function _is_windows() return sep == '\\' 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' if (not prefer_no_console) or (not _is_windows()) then return python @@ -1711,6 +1705,75 @@ local function _is_http_url(u) return u:match('^https?://') ~= nil 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) if type(url) ~= 'string' or url == '' then return @@ -1741,59 +1804,97 @@ end function FileState:fetch_formats(cb) local url = tostring(self.url or '') + _lua_log('fetch-formats: started for url=' .. url) 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 return end if _extract_store_hash(url) then + _lua_log('fetch-formats: skipped (store-hash)') if cb then cb(false, 'store-hash url') end return end local cached = _get_cached_formats_table(url) if type(cached) == 'table' then + _lua_log('fetch-formats: using cached table') self:set_formats(url, cached) if cb then cb(true, nil) end return 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 {} 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) - _formats_inflight[url] = nil + _run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err) + _lua_log('fetch-formats: IPC callback received id=' .. tostring(resp and resp.id) .. ' err=' .. tostring(err)) + _formats_inflight[url] = nil - local ok = false - local reason = err - if resp and resp.success and type(resp.table) == 'table' then - ok = true - reason = nil - self:set_formats(url, resp.table) - _cache_formats_for_url(url, resp.table) - _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url') - else - if type(resp) == 'table' then - if resp.error and tostring(resp.error) ~= '' then - reason = tostring(resp.error) - elseif resp.stderr and tostring(resp.stderr) ~= '' then - reason = tostring(resp.stderr) + local ok = false + local reason = err + if resp and resp.success and type(resp.table) == 'table' then + ok = true + reason = nil + self:set_formats(url, resp.table) + _cache_formats_for_url(url, resp.table) + _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url') + else + _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 end end - end - local waiters = _formats_waiters[url] or {} - _formats_waiters[url] = nil - for _, fn in ipairs(waiters) do - pcall(fn, ok, reason) - end - end) + 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 + + 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 local function _prefetch_formats_for_url(url) @@ -1847,6 +1948,38 @@ local function _debug_dump_formatted_formats(url, tbl, items) 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() -- Preferred: mpv exposes the active ytdl format string. 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 _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) return end @@ -2155,6 +2289,7 @@ mp.register_script_message('medios-change-format-current', function() _pending_format_change.formats_table = 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) end) end @@ -2392,9 +2527,10 @@ function M.run_pipeline(pipeline_cmd, seeds, cb) end ensure_mpv_ipc_server() - -- Use shorter timeout for .mpv -url commands since they just queue the URL (non-blocking) - -- The actual URL resolution happens asynchronously in MPV itself - local timeout_seconds = pipeline_cmd:match('%.mpv%s+%-url') and 10 or 30 + -- 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 _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)) if resp and resp.success then @@ -2878,23 +3014,19 @@ mp.register_script_message('medios-load-url-event', function(json) -- First, always try direct loadfile. This is the fastest path. local can_direct = _url_can_direct_load(url) _lua_log('[LOAD-URL] Checking if URL can be loaded directly: ' .. tostring(can_direct)) - - if can_direct then - _lua_log('[LOAD-URL] Attempting direct loadfile') - local ok_load = pcall(mp.commandv, 'loadfile', url, 'replace') - if ok_load then - _lua_log('[LOAD-URL] Direct loadfile command sent successfully') - _log_all('INFO', 'Load URL succeeded via direct load') - mp.osd_message('URL loaded', 2) - close_menu() - return - else - _lua_log('[LOAD-URL] Direct loadfile command failed') - _log_all('ERROR', 'Load URL failed: direct loadfile command failed') - mp.osd_message('Load URL failed (direct)', 3) - close_menu() - return - end + + local direct_ok, direct_loaded = _try_direct_loadfile(url, false) + 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) + close_menu() + 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') end -- 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') _log_all('INFO', 'Load URL succeeded') 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() end) end) @@ -2964,6 +3105,9 @@ end) function M.show_menu() _lua_log('[MENU] M.show_menu called') + local target = _current_target() + _lua_log('[MENU] current target: ' .. tostring(target)) + -- Build menu items -- Note: UOSC expects command strings, not arrays local items = { @@ -2972,12 +3116,10 @@ function M.show_menu() { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" }, { title = "Cmd", value = "script-message medios-open-cmd", hint = "screenshot/trim/etc" }, { 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 not _is_pipeline_helper_ready() then - table.insert(items, { title = "Start Helper", hint = "(pipeline actions)", value = "script-message medios-start-helper" }) + if _is_ytdlp_url(target) then + table.insert(items, { title = "Change Format", value = "script-message medios-change-format-current" }) end _lua_log('[MENU] Built ' .. #items .. ' menu items') @@ -3057,6 +3199,14 @@ end) mp.add_timeout(0, function() pcall(ensure_mpv_ipc_server) 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) mp.add_timeout(1.0, function() diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 52a395f..0a843a1 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -376,31 +376,14 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: "table": None, } - # Fast gate: only for streaming URLs yt-dlp knows about. try: - from tool.ytdlp import is_url_supported_by_ytdlp # 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 + from tool.ytdlp import list_formats, is_browseable_format # noqa: WPS433 except Exception as exc: return { "success": False, "stdout": "", "stderr": "", - "error": - f"yt-dlp module not available: {type(exc).__name__}: {exc}", + "error": f"yt-dlp tool unavailable: {type(exc).__name__}: {exc}", "table": None, } @@ -415,96 +398,27 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: except Exception: 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: """Format bytes using centralized utility.""" return format_bytes(n) - with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined] - info = ydl.extract_info(url, download=False) + formats = list_formats( + url, + no_playlist=True, + cookiefile=cookiefile, + timeout_seconds=25, + ) - # Debug: dump a short summary of the format list to the helper log. - 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): + if formats is None: return { "success": False, "stdout": "", "stderr": "", - "error": "yt-dlp returned non-dict info", + "error": "yt-dlp format probe failed or timed out", "table": None, } - formats = info.get("formats") - if not isinstance(formats, list) or not formats: + if not formats: return { "success": True, "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 = [] for fmt in formats: 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() 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. - 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( { @@ -962,6 +925,28 @@ def main(argv: Optional[list[str]] = None) -> int: 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 try: diff --git a/MPV/portable_config/mpv.conf b/MPV/portable_config/mpv.conf index ae541ff..35d5ee0 100644 --- a/MPV/portable_config/mpv.conf +++ b/MPV/portable_config/mpv.conf @@ -4,6 +4,10 @@ osd-bar=no ytdl=yes # uosc will draw its own window controls and border if you disable window border 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. osd-fonts-dir=~~/scripts/uosc/fonts diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index bab15d6..81bd2bf 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -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." ) - # Print database logs for mpv module (helper output) + # Print database logs for mpv module (helper + lua output) try: import sqlite3 log_db_path = str((Path(__file__).resolve().parent.parent / "logs.db")) conn = sqlite3.connect(log_db_path, timeout=5.0) 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] = [] if log_filter_text: query += " AND LOWER(message) LIKE ?" @@ -2085,57 +2085,24 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: cur.close() conn.close() 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: - print("Helper logs from database (mpv module, most recent first):") + print("MPV logs from database (mpv module, most recent first):") if mpv_logs: - for level, module, message in mpv_logs: - print(f"[{level}] {message}") + for timestamp, level, module, message in mpv_logs: + ts = str(timestamp or "").strip() + if ts: + print(f"[{ts}] [{level}] {message}") + else: + print(f"[{level}] {message}") else: 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: - print("(no helper logs found)") + print("(no mpv logs found)") except Exception as e: debug(f"Could not fetch database logs: {e}") 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): ") - 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): ") - except Exception: - pass except Exception: pass try: