From b0e89ff950327f4098bd9c5bd14dad214a42bb14 Mon Sep 17 00:00:00 2001 From: Nose Date: Wed, 18 Mar 2026 01:26:55 -0700 Subject: [PATCH] f --- API/data/alldebrid.json | 4 +- MPV/LUA/main.lua | 1368 ++++++++++++++++--- MPV/format_probe.py | 48 + MPV/pipeline_helper.py | 99 +- MPV/portable_config/script-opts/medeia.conf | 2 +- cmdnat/pipe.py | 16 + 6 files changed, 1310 insertions(+), 227 deletions(-) create mode 100644 MPV/format_probe.py diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 6034744..52119c2 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -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": true + "status": false }, "mega": { "name": "mega", @@ -595,7 +595,7 @@ "(simfileshare\\.net/download/[0-9]+/)" ], "regexp": "(simfileshare\\.net/download/[0-9]+/)", - "status": false + "status": true }, "streamtape": { "name": "streamtape", diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 640c2c2..d494860 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -54,6 +54,116 @@ local PIPELINE_RESP_PROP = 'user-data/medeia-pipeline-response' local PIPELINE_READY_PROP = 'user-data/medeia-pipeline-ready' local CURRENT_WEB_URL_PROP = 'user-data/medeia-current-web-url' +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) + end + return '' +end + +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 + 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 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 + if base ~= '' then + local cli = find_up(base, 'CLI.py', 8) + if cli and cli ~= '' then + return cli:match('(.*)[/\\]') or '' + end + end + end + + 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) @@ -62,36 +172,11 @@ local function _lua_log(text) return end - -- 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 - 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 + _append_lua_log_file(payload) + _emit_to_mpv_log(payload) - local base = mp.get_script_directory() or utils.getcwd() or '' - if base ~= '' then - local cli = find_up(base, 'CLI.py', 8) - if cli and cli ~= '' then - repo_root = cli:match('(.*)[/\\]') or '' - end - end - end + -- Attempt to find repo root for database access + local repo_root = _detect_repo_root() -- Write to logs.db via Python subprocess (non-blocking, async) if repo_root ~= '' then @@ -106,7 +191,7 @@ local function _lua_log(text) ) pcall(function() - mp.command_native_async({ name = 'subprocess', args = { python, '-c', script }, cwd = nil }, function() end) + mp.command_native_async({ name = 'subprocess', args = { python, '-c', script } }, function() end) end) end end @@ -293,6 +378,164 @@ local function _append_unique_path(out, seen, path) out[#out + 1] = path end +local function _path_exists(path) + path = trim(tostring(path or '')) + if path == '' then + return false + end + return utils.file_info(path) ~= nil +end + +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 + local function _build_sibling_script_candidates(file_name) local candidates = {} local seen = {} @@ -345,6 +588,7 @@ local _resolve_python_exe local _cached_store_names = {} local _store_cache_loaded = false +local _store_cache_retry_pending = false -- Optional index into _cached_store_names (used by some older menu code paths). -- If unset, callers should fall back to reading SELECTED_STORE_PROP. @@ -353,6 +597,16 @@ local _selected_store_index = nil local SELECTED_STORE_PROP = 'user-data/medeia-selected-store' local STORE_PICKER_MENU_TYPE = 'medeia_store_picker' local _selected_store_loaded = false +local _current_url_for_web_actions +local _store_status_hint_for_url +local _refresh_current_store_url_status +local _skip_next_store_check_url = '' + +local function _normalize_store_name(store) + store = trim(tostring(store or '')) + store = store:gsub('^"', ''):gsub('"$', '') + return trim(store) +end local function _get_script_opts_dir() local dir = nil @@ -388,7 +642,7 @@ local function _load_selected_store_from_disk() local k, v = s:match('^([%w_%-]+)%s*=%s*(.*)$') if k and v and k:lower() == 'store' then fh:close() - v = trim(tostring(v or '')) + v = _normalize_store_name(v) return v ~= '' and v or nil end end @@ -417,11 +671,11 @@ local function _get_selected_store() pcall(function() v = tostring(mp.get_property(SELECTED_STORE_PROP) or '') end) - return trim(tostring(v or '')) + return _normalize_store_name(v) end local function _set_selected_store(store) - store = trim(tostring(store or '')) + store = _normalize_store_name(store) pcall(mp.set_property, SELECTED_STORE_PROP, store) pcall(_save_selected_store_to_disk, store) end @@ -435,7 +689,7 @@ local function _ensure_selected_store_loaded() pcall(function() disk = _load_selected_store_from_disk() end) - disk = trim(tostring(disk or '')) + disk = _normalize_store_name(disk) if disk ~= '' then pcall(mp.set_property, SELECTED_STORE_PROP, disk) end @@ -501,6 +755,21 @@ local function _is_pipeline_helper_ready() return false end +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 + 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 '') + .. ' last_value=' .. tostring(_helper_ready_last_value or '') + .. ' last_seen_age=' .. tostring(age) +end + local function get_mpv_ipc_path() local ipc = mp.get_property('input-ipc-server') if ipc and ipc ~= '' then @@ -561,28 +830,23 @@ local function attempt_start_pipeline_helper_async(callback) return end - local script_dir = mp.get_script_directory() or utils.getcwd() or '' - local cli = nil - pcall(function() - cli = find_file_upwards(script_dir, 'CLI.py', 8) - end) - local cwd = nil - if cli and cli ~= '' then - cwd = cli:match('(.*)[/\\]') or nil - end + local cwd = _detect_repo_root() + local cwd_arg = cwd ~= '' and cwd or nil local args = { python, '-m', 'MPV.pipeline_helper', '--ipc', get_mpv_ipc_path(), '--timeout', '30' } - _lua_log('attempt_start_pipeline_helper_async: spawning helper') + _lua_log('attempt_start_pipeline_helper_async: spawning helper python=' .. tostring(python) .. ' cwd=' .. tostring(cwd_arg or '')) -- Spawn detached; don't wait for it here (async). - local ok = pcall(mp.command_native, { name = 'subprocess', args = args, cwd = cwd, detach = true }) + local ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args, cwd = cwd_arg, detach = true }) + _lua_log('attempt_start_pipeline_helper_async: detached spawn result ' .. tostring(detail or '')) if not ok then _lua_log('attempt_start_pipeline_helper_async: detached spawn failed, retrying blocking') - ok = pcall(mp.command_native, { name = 'subprocess', args = args, cwd = cwd }) + ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args, cwd = cwd_arg }) + _lua_log('attempt_start_pipeline_helper_async: blocking spawn result ' .. tostring(detail or '')) end if not ok then - _lua_log('attempt_start_pipeline_helper_async: spawn failed') + _lua_log('attempt_start_pipeline_helper_async: spawn failed final=' .. tostring(detail or _describe_subprocess_result(result))) callback(false) return end @@ -738,6 +1002,7 @@ local function _run_helper_request_async(req, timeout_seconds, cb) end if mp.get_time() >= deadline then ready_timer:kill() + _lua_log('ipc-async: helper wait timed out ' .. _helper_ready_diagnostics()) done(nil, 'helper not ready') return end @@ -769,6 +1034,7 @@ local function _run_helper_request_async(req, timeout_seconds, cb) end if mp.get_time() >= helper_deadline then helper_timer:kill() + _lua_log('ipc-async: helper still not running after auto-start ' .. _helper_ready_diagnostics()) done(nil, 'helper not running') return end @@ -916,27 +1182,20 @@ local function _is_windows() end _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 - end + local configured = trim(tostring((opts and opts.python_path) or 'python')) + local candidates = _build_python_candidates(configured, prefer_no_console) - local low = tostring(python):lower() - if low == 'python' then - return 'pythonw' - end - if low == 'python.exe' then - return 'pythonw.exe' - end - if low:sub(-10) == 'python.exe' then - local candidate = python:sub(1, #python - 10) .. 'pythonw.exe' - if utils.file_info(candidate) then + for _, candidate in ipairs(candidates) do + if candidate:match('[/\\]') then + if _path_exists(candidate) then + return candidate + end + else return candidate end - return 'pythonw' end - -- Already pythonw or some other launcher. - return python + + return configured ~= '' and configured or 'python' end local function _extract_target_from_memory_uri(text) @@ -980,6 +1239,137 @@ local function _extract_query_param(url, key) return nil end +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 + local pipeline_cmd = 'search-file -query ' .. quote_pipeline_arg(query) .. ' | output-json' + + _lua_log('store-check: probing global query=' .. tostring(query)) + _run_helper_request_async({ pipeline = pipeline_cmd }, 4.0, function(resp, err) + if resp and resp.success then + local output = trim(tostring(resp.stdout or '')) + if output == '' then + run_next(nil) + return + end + + local ok, data = pcall(utils.parse_json, output) + if ok and type(data) == 'table' then + if #data > 0 then + cb(data, nil, needle) + return + end + run_next(nil) + return + end + + run_next('malformed JSON response') + 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 + local function _current_target() local path = mp.get_property('path') if not path or path == '' then @@ -1727,8 +2117,25 @@ local function _refresh_store_cache(timeout_seconds, on_complete) _lua_log('stores: cache_empty cached_json=' .. tostring(cached_json)) end + if not _is_pipeline_helper_ready() then + if not _store_cache_retry_pending then + _store_cache_retry_pending = true + _lua_log('stores: helper not ready; deferring dynamic store refresh') + attempt_start_pipeline_helper_async(function(success) + _lua_log('stores: deferred helper start success=' .. tostring(success)) + end) + mp.add_timeout(1.0, function() + _store_cache_retry_pending = false + _refresh_store_cache(timeout_seconds, on_complete) + end) + else + _lua_log('stores: helper not ready; refresh already deferred') + end + return false + end + _lua_log('stores: requesting store-choices via helper (fallback)') - _run_helper_request_async({ op = 'store-choices' }, timeout_seconds or 1, function(resp, err) + _run_helper_request_async({ op = 'store-choices' }, math.max(timeout_seconds or 0, 3.0), function(resp, err) local success = false local changed = false if resp and resp.success and type(resp.choices) == 'table' then @@ -1800,6 +2207,7 @@ local function _open_store_picker() local function build_items() local selected = _get_selected_store() + local current_url = trim(tostring(_current_url_for_web_actions() or _current_target() or '')) local items = {} if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then @@ -1807,8 +2215,13 @@ local function _open_store_picker() name = trim(tostring(name or '')) if name ~= '' then local payload = { store = name } + 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 items[#items + 1] = { title = name, + hint = hint, active = (selected ~= '' and name == selected) and true or false, value = { 'script-message-to', mp.get_script_name(), 'medeia-store-select', utils.format_json(payload) }, } @@ -1865,6 +2278,7 @@ mp.register_script_message('medeia-store-select', function(json) end _set_selected_store(store) mp.osd_message('Store: ' .. store, 2) + _refresh_current_store_url_status('store-select') end) -- No-op handler for placeholder menu items. @@ -1919,7 +2333,12 @@ M.file = M.file or FileState.new() local _formats_cache = {} local _formats_inflight = {} local _formats_waiters = {} +local _formats_prefetch_retries = {} +local _format_cache_poll_generation = 0 +local _last_raw_format_summary = '' local _get_cached_formats_table +local _debug_dump_formatted_formats +local _show_format_list_osd local _ipc_async_busy = false local _ipc_async_queue = {} @@ -2024,7 +2443,7 @@ local function _get_current_web_url() return nil end -local function _current_url_for_web_actions() +_current_url_for_web_actions = function() local current = _get_current_web_url() if current and current ~= '' then return current @@ -2064,6 +2483,138 @@ local function _sync_current_web_url_from_playback() end end +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 '')) +end + +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 + _lua_log('store-check: helper not ready; deferring reason=' .. tostring(reason) .. ' store=' .. tostring(store)) + attempt_start_pipeline_helper_async(function(success) + if _current_store_url_status.generation ~= generation then + return + end + if not success then + _set_current_store_url_status(store, url, 'error', 'helper not running') + return + end + mp.add_timeout(0.1, function() + if _current_store_url_status.generation ~= generation then + return + end + _refresh_current_store_url_status((reason ~= '' and (reason .. '-retry')) or 'retry') + end) + end) + 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) + _lua_log('store-check: failed err=' .. tostring(err_text)) + 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 @@ -2092,6 +2643,334 @@ _get_cached_formats_table = function(url) return nil end +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 + function FileState:fetch_formats(cb) local url = tostring(self.url or '') _lua_log('fetch-formats: started for url=' .. url) @@ -2115,6 +2994,17 @@ function FileState:fetch_formats(cb) return end + 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 + local function _perform_request() if _formats_inflight[url] then _lua_log('fetch-formats: already inflight, adding waiter') @@ -2122,13 +3012,13 @@ function FileState:fetch_formats(cb) if cb then table.insert(_formats_waiters[url], cb) end return end - _lua_log('fetch-formats: initiating IPC request') + _lua_log('fetch-formats: initiating subprocess probe') _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) - _lua_log('fetch-formats: IPC callback received id=' .. tostring(resp and resp.id) .. ' err=' .. tostring(err)) + _run_formats_probe_async(url, function(resp, err) + _lua_log('fetch-formats: subprocess callback received err=' .. tostring(err)) _formats_inflight[url] = nil local ok = false @@ -2138,7 +3028,7 @@ function FileState:fetch_formats(cb) 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') + _lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url via subprocess probe') else _lua_log('fetch-formats: request failed success=' .. tostring(resp and resp.success)) if type(resp) == 'table' then @@ -2159,39 +3049,30 @@ function FileState:fetch_formats(cb) 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) +local function _prefetch_formats_for_url(url, attempt) url = tostring(url or '') if url == '' or not _is_http_url(url) then return end + 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 + _set_current_web_url(url) if type(M.file) == 'table' then if M.file.set_url then @@ -2200,7 +3081,45 @@ local function _prefetch_formats_for_url(url) M.file.url = url end if M.file.fetch_formats then - M.file:fetch_formats(nil) + 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) end end end @@ -2215,7 +3134,7 @@ local function _open_loading_formats_menu(title) }) end -local function _debug_dump_formatted_formats(url, tbl, items) +_debug_dump_formatted_formats = function(url, tbl, items) local row_count = 0 if type(tbl) == 'table' and type(tbl.rows) == 'table' then row_count = #tbl.rows @@ -2243,7 +3162,7 @@ local function _debug_dump_formatted_formats(url, tbl, items) end end -local function _show_format_list_osd(items, max_items) +_show_format_list_osd = function(items, max_items) if type(items) ~= 'table' then return end @@ -2310,23 +3229,96 @@ local function _current_ytdl_format_string() return nil end +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 + +local function _run_pipeline_cli_detached(pipeline_cmd, seeds) + pipeline_cmd = trim(tostring(pipeline_cmd or '')) + if pipeline_cmd == '' then + return false, 'empty pipeline command' + 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 + return false, 'python not found' + 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 + + local cmd = { + name = 'subprocess', + args = args, + detach = true, + } + local repo_root = _detect_repo_root() + if repo_root ~= '' then + cmd.cwd = repo_root + end + + 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 + local function _run_pipeline_detached(pipeline_cmd, on_failure) if not pipeline_cmd or pipeline_cmd == '' then return false end + local ok, detail = _run_pipeline_cli_detached(pipeline_cmd, nil) + if ok then + return true + end + ensure_mpv_ipc_server() if not ensure_pipeline_helper_running() then if type(on_failure) == 'function' then - on_failure(nil, 'helper not running') + on_failure(nil, detail ~= '' and detail or 'helper not running') end return false end + _run_helper_request_async({ op = 'run-detached', data = { pipeline = pipeline_cmd } }, 1.0, function(resp, err) if resp and resp.success then return end if type(on_failure) == 'function' then - on_failure(resp, err) + on_failure(resp, err or detail) end end) return true @@ -2338,6 +3330,7 @@ local function _open_save_location_picker_for_pending_download() end local function build_items() + local selected = _get_selected_store() local items = { { title = 'Pick folder…', @@ -2351,8 +3344,14 @@ local function _open_save_location_picker_for_pending_download() name = trim(tostring(name or '')) if name ~= '' then local payload = { store = name } + 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 items[#items + 1] = { title = name, + hint = hint, + active = (selected ~= '' and name == selected) and true or false, value = { 'script-message-to', mp.get_script_name(), 'medios-download-pick-store', utils.format_json(payload) }, } end @@ -2383,6 +3382,7 @@ mp.add_timeout(0.10, function() if not _store_cache_loaded then pcall(_refresh_store_cache, 1.5) end + pcall(_refresh_current_store_url_status, 'startup') end) local function _apply_ytdl_format_and_reload(url, fmt) @@ -2392,18 +3392,30 @@ local function _apply_ytdl_format_and_reload(url, fmt) local pos = mp.get_property_number('time-pos') local paused = mp.get_property_native('pause') and true or false + local media_title = trim(tostring(mp.get_property('media-title') or '')) _lua_log('change-format: setting ytdl format=' .. tostring(fmt)) + _skip_next_store_check_url = _normalize_url_for_store_lookup(url) _set_current_web_url(url) pcall(mp.set_property, 'options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'file-local-options/ytdl-format', tostring(fmt)) pcall(mp.set_property, 'ytdl-format', tostring(fmt)) + local load_options = { + ['ytdl-format'] = tostring(fmt), + } if pos and pos > 0 then - mp.commandv('loadfile', url, 'replace', 'start=' .. tostring(pos)) - else - mp.commandv('loadfile', url, 'replace') + load_options['start'] = tostring(pos) end + 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 }) if paused then mp.set_property_native('pause', true) @@ -2431,12 +3443,13 @@ local function _start_download_flow_for_current() 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) - M.run_pipeline(pipeline_cmd, nil, function(_, err) - if err then - mp.osd_message('Download failed: ' .. tostring(err), 5) - end - end) - mp.osd_message('Download started', 2) + if _run_pipeline_detached(pipeline_cmd, function(_, err) + mp.osd_message('Download failed to start: ' .. tostring(err or 'unknown'), 5) + end) then + mp.osd_message('Download started', 2) + else + mp.osd_message('Download failed to start', 5) + end return end @@ -2493,40 +3506,12 @@ mp.register_script_message('medios-change-format-current', function() else cached_tbl = _get_cached_formats_table(url) end + 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 if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then _pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl } - - local items = {} - for idx, row in ipairs(cached_tbl.rows) do - local cols = row.columns or {} - local id_val = '' - local res_val = '' - local ext_val = '' - local size_val = '' - for _, c in ipairs(cols) do - if c.name == 'ID' then id_val = tostring(c.value or '') end - if c.name == 'Resolution' then res_val = tostring(c.value or '') end - if c.name == 'Ext' then ext_val = tostring(c.value or '') end - if c.name == 'Size' then size_val = tostring(c.value or '') end - end - local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx)) - local hint_parts = {} - if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end - if ext_val ~= '' then table.insert(hint_parts, ext_val) end - if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end - local hint = table.concat(hint_parts, ' | ') - - local payload = { index = idx } - items[#items + 1] = { - title = label, - hint = hint, - value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) }, - } - end - - _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) + _open_format_picker_for_table(url, cached_tbl) return end @@ -2564,38 +3549,8 @@ mp.register_script_message('medios-change-format-current', function() return end - local items = {} - for idx, row in ipairs(tbl.rows) do - local cols = row.columns or {} - local id_val = '' - local res_val = '' - local ext_val = '' - local size_val = '' - for _, c in ipairs(cols) do - if c.name == 'ID' then id_val = tostring(c.value or '') end - if c.name == 'Resolution' then res_val = tostring(c.value or '') end - if c.name == 'Ext' then ext_val = tostring(c.value or '') end - if c.name == 'Size' then size_val = tostring(c.value or '') end - end - local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx)) - local hint_parts = {} - if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end - if ext_val ~= '' then table.insert(hint_parts, ext_val) end - if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end - local hint = table.concat(hint_parts, ' | ') - - local payload = { index = idx } - items[#items + 1] = { - title = label, - hint = hint, - value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) }, - } - end - _pending_format_change.formats_table = tbl - _debug_dump_formatted_formats(url, tbl, items) - _show_format_list_osd(items, 8) - _uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items) + _open_format_picker_for_table(url, tbl) end) end end) @@ -2603,6 +3558,7 @@ end) -- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant. mp.register_event('file-loaded', function() _sync_current_web_url_from_playback() + _refresh_current_store_url_status('file-loaded') local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then return @@ -2611,9 +3567,27 @@ mp.register_event('file-loaded', function() if not _is_http_url(url) then return end + 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) _prefetch_formats_for_url(url) end) +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) + mp.register_script_message('medios-change-format-pick', function(json) if type(_pending_format_change) ~= 'table' or not _pending_format_change.url then return @@ -2667,23 +3641,18 @@ mp.register_script_message('medios-download-pick-store', function(json) local url = tostring(_pending_download.url) local fmt = tostring(_pending_download.format) - local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt) + local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) + .. ' -query ' .. quote_pipeline_arg('format:' .. fmt) .. ' | add-file -store ' .. quote_pipeline_arg(store) - local function run_pipeline_direct() - M.run_pipeline(pipeline_cmd, nil, function(_, err) - if err then - mp.osd_message('Download failed: ' .. tostring(err), 5) - end - end) - end - - if not _run_pipeline_detached(pipeline_cmd, function() - run_pipeline_direct() + _set_selected_store(store) + if _run_pipeline_detached(pipeline_cmd, function(_, err) + mp.osd_message('Download failed to start: ' .. tostring(err or 'unknown'), 5) end) then - run_pipeline_direct() + mp.osd_message('Download started', 3) + else + mp.osd_message('Download failed to start', 5) end - mp.osd_message('Download started', 3) _pending_download = nil end) @@ -2704,23 +3673,17 @@ mp.register_script_message('medios-download-pick-path', function() local url = tostring(_pending_download.url) local fmt = tostring(_pending_download.format) - local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) .. ' -format ' .. quote_pipeline_arg(fmt) + local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) + .. ' -query ' .. quote_pipeline_arg('format:' .. fmt) .. ' | add-file -path ' .. quote_pipeline_arg(folder) - local function run_pipeline_direct() - M.run_pipeline(pipeline_cmd, nil, function(_, err) - if err then - mp.osd_message('Download failed: ' .. tostring(err), 5) - end - end) - end - - if not _run_pipeline_detached(pipeline_cmd, function() - run_pipeline_direct() + if _run_pipeline_detached(pipeline_cmd, function(_, err) + mp.osd_message('Download failed to start: ' .. tostring(err or 'unknown'), 5) end) then - run_pipeline_direct() + mp.osd_message('Download started', 3) + else + mp.osd_message('Download failed to start', 5) end - mp.osd_message('Download started', 3) _pending_download = nil end) @@ -3395,6 +4358,11 @@ function M.show_menu() _lua_log('[MENU] M.show_menu called') local target = _current_target() + 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 _lua_log('[MENU] current target: ' .. tostring(target)) -- Build menu items @@ -3404,7 +4372,7 @@ function M.show_menu() { title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" }, { 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 = "Download", value = "script-message medios-download-current", hint = download_hint }, } if _is_ytdlp_url(target) then diff --git a/MPV/format_probe.py b/MPV/format_probe.py new file mode 100644 index 0000000..dae2acc --- /dev/null +++ b/MPV/format_probe.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +from pathlib import Path +from typing import Any, Dict + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + payload: Dict[str, Any] = { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing url", + "table": None, + } + print(json.dumps(payload, ensure_ascii=False)) + return 2 + + url = str(args[0] or "").strip() + captured_stdout = io.StringIO() + captured_stderr = io.StringIO() + with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr): + from MPV.pipeline_helper import _run_op + + payload = _run_op("ytdlp-formats", {"url": url}) + + noisy_stdout = captured_stdout.getvalue().strip() + noisy_stderr = captured_stderr.getvalue().strip() + if noisy_stdout: + payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout])) + if noisy_stderr: + payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr])) + + print(json.dumps(payload, ensure_ascii=False)) + return 0 if payload.get("success") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 0a843a1..2329b05 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -35,7 +35,7 @@ import hashlib import subprocess import platform from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional def _repo_root() -> Path: @@ -73,6 +73,44 @@ READY_PROP = "user-data/medeia-pipeline-ready" OBS_ID_REQUEST = 1001 +_HELPER_MPV_LOG_EMITTER: Optional[Callable[[str], None]] = None +_HELPER_LOG_BACKLOG: list[str] = [] +_HELPER_LOG_BACKLOG_LIMIT = 200 + + +def _start_ready_heartbeat(ipc_path: str, stop_event: threading.Event) -> threading.Thread: + """Keep READY_PROP fresh even when the main loop blocks on Windows pipes.""" + + def _heartbeat_loop() -> None: + hb_client = MPVIPCClient(socket_path=ipc_path, timeout=0.5, silent=True) + while not stop_event.is_set(): + try: + if hb_client.sock is None and not hb_client.connect(): + stop_event.wait(0.25) + continue + hb_client.send_command_no_wait( + ["set_property_string", READY_PROP, str(int(time.time()))] + ) + except Exception: + try: + hb_client.disconnect() + except Exception: + pass + stop_event.wait(0.75) + + try: + hb_client.disconnect() + except Exception: + pass + + thread = threading.Thread( + target=_heartbeat_loop, + name="mpv-helper-heartbeat", + daemon=True, + ) + thread.start() + return thread + def _run_pipeline(pipeline_text: str, *, seeds: Any = None) -> Dict[str, Any]: # Import after sys.path fix. @@ -297,14 +335,11 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: 0) or 0), } - # Provide store backend choices using the same source as CLI/Typer autocomplete. + # Provide store backend choices using the dynamic registered store registry only. if op_name in {"store-choices", "store_choices", "get-store-choices", "get_store_choices"}: - # IMPORTANT: - # - Prefer runtime cwd for config discovery (mpv spawns us with cwd=repo_root). - # - Avoid returning a cached empty result if config was loaded before it existed. try: from SYS.config import reload_config # noqa: WPS433 from Store import Store # noqa: WPS433 @@ -317,25 +352,6 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: choices = sorted({str(n) for n in backends if str(n).strip()}) - # Fallback: if initialization gated all backends (e.g., missing deps or offline stores), - # still return configured instance names so the UI can present something. - if not choices: - store_cfg = cfg.get("store") if isinstance(cfg, dict) else None - if isinstance(store_cfg, dict): - seen = set() - for _, instances in store_cfg.items(): - if not isinstance(instances, dict): - continue - for instance_key, instance_cfg in instances.items(): - name = None - if isinstance(instance_cfg, dict): - name = instance_cfg.get("NAME" - ) or instance_cfg.get("name") - candidate = str(name or instance_key or "").strip() - if candidate: - seen.add(candidate) - choices = sorted(seen) - debug(f"[store-choices] config_dir={config_root} choices={len(choices)}") return { @@ -564,6 +580,11 @@ def _append_helper_log(text: str) -> None: payload = (text or "").rstrip() if not payload: return + + _HELPER_LOG_BACKLOG.append(payload) + if len(_HELPER_LOG_BACKLOG) > _HELPER_LOG_BACKLOG_LIMIT: + del _HELPER_LOG_BACKLOG[:-_HELPER_LOG_BACKLOG_LIMIT] + try: # Try database logging first (best practice: unified logging) from SYS.database import log_to_db @@ -573,6 +594,13 @@ def _append_helper_log(text: str) -> None: import sys print(f"[mpv-helper] {payload}", file=sys.stderr) + emitter = _HELPER_MPV_LOG_EMITTER + if emitter is not None: + try: + emitter(payload) + except Exception: + pass + def _helper_log_path() -> str: try: @@ -800,6 +828,25 @@ def main(argv: Optional[list[str]] = None) -> int: # Keep trying. time.sleep(0.10) + def _emit_helper_log_to_mpv(payload: str) -> None: + safe = str(payload or "").replace("\r", " ").replace("\n", " ").strip() + if not safe: + return + if len(safe) > 900: + safe = safe[:900] + "..." + try: + client.send_command_no_wait(["print-text", f"medeia-helper: {safe}"]) + except Exception: + return + + global _HELPER_MPV_LOG_EMITTER + _HELPER_MPV_LOG_EMITTER = _emit_helper_log_to_mpv + for backlog_line in list(_HELPER_LOG_BACKLOG): + try: + _emit_helper_log_to_mpv(backlog_line) + except Exception: + break + # Mark ready ASAP and keep it fresh. # Use a unix timestamp so the Lua side can treat it as a heartbeat. last_ready_ts: float = 0.0 @@ -862,6 +909,9 @@ def main(argv: Optional[list[str]] = None) -> int: except Exception: pass + heartbeat_stop = threading.Event() + _start_ready_heartbeat(str(args.ipc), heartbeat_stop) + # Pre-compute store choices at startup and publish to a cached property so Lua # can read immediately without waiting for a request/response cycle (which may timeout). try: @@ -967,6 +1017,7 @@ def main(argv: Optional[list[str]] = None) -> int: _flush_mpv_repeat() except Exception: pass + heartbeat_stop.set() return 0 if msg.get("event") == "log-message": diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf index cb20eb2..3d30edf 100644 --- a/MPV/portable_config/script-opts/medeia.conf +++ b/MPV/portable_config/script-opts/medeia.conf @@ -1,2 +1,2 @@ # Medeia MPV script options -store= +store=local diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index 18b97d4..70f4541 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -2381,6 +2381,22 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: except Exception as e: debug(f"Could not fetch database logs: {e}") pass + + fallback_logs = [ + ("Medeia Lua log file tail", str(_lua_log_file())), + ("Medeia helper log file tail", str(_helper_log_file())), + ] + for title, path in fallback_logs: + try: + lines = _tail_text_file(path, max_lines=120) + except Exception: + lines = [] + lines = _apply_log_filter(lines, log_filter_text) + if not lines: + continue + print(f"{title}:") + for line in lines: + print(line) except Exception: pass try: