diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 1526c78..b0946ab 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -353,7 +353,7 @@ "filedot\\.(xyz|to|top)/([0-9a-zA-Z]{12})" ], "regexp": "filedot\\.(xyz|to|top)/([0-9a-zA-Z]{12})", - "status": true + "status": false }, "filefactory": { "name": "filefactory", diff --git a/CLI.py b/CLI.py index 3792587..883b56c 100644 --- a/CLI.py +++ b/CLI.py @@ -31,6 +31,7 @@ if not os.environ.get("MM_DEBUG"): except Exception: pass +import httpx import json import shlex import sys @@ -1681,6 +1682,8 @@ Come to love it when others take what you share, as there is no greater joy code = int(getattr(resp, "status_code", 0) or 0) ok = 200 <= code < 500 return ok, f"{url} (HTTP {code})" + except httpx.TimeoutException: + return False, f"{url} (timeout)" except Exception as exc: return False, f"{url} ({type(exc).__name__})" diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 776fbdf..92513dc 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -275,6 +275,12 @@ local opts = { cli_path = nil -- Will be auto-detected if nil } +-- Read script options from script-opts/medeia.conf when available +pcall(function() + local mpopts = require('mp.options') + mpopts.read_options(opts, 'medeia') +end) + local function find_file_upwards(start_dir, relative_path, max_levels) local dir = start_dir local levels = max_levels or 6 @@ -397,6 +403,10 @@ local _last_ipc_error = '' local _last_ipc_last_req_json = '' local _last_ipc_last_resp_json = '' +-- Debounce helper start attempts (window in seconds) +local _helper_start_debounce_ts = 0 +local HELPER_START_DEBOUNCE = 2.0 + local function _is_pipeline_helper_ready() local ready = mp.get_property(PIPELINE_READY_PROP) if ready == nil or ready == '' then @@ -410,7 +420,7 @@ local function _is_pipeline_helper_ready() return false end - -- Back-compat: older helpers may set "1". New helpers set unix timestamps. + -- 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 @@ -424,7 +434,7 @@ local function _is_pipeline_helper_ready() return age <= 10 end - -- If it's some other non-empty value, treat as ready. + -- Non-empty value treated as ready return true end @@ -463,11 +473,80 @@ local function ensure_mpv_ipc_server() return (now and now ~= '') and true or false end +local function attempt_start_pipeline_helper_async(callback) + -- Async version: spawn helper without blocking UI. Calls callback(success) when done. + callback = callback or function() end + + if _is_pipeline_helper_ready() then + callback(true) + return + end + + -- Debounce: don't spawn multiple helpers in quick succession + local now = mp.get_time() + if (now - _helper_start_debounce_ts) < HELPER_START_DEBOUNCE then + _lua_log('attempt_start_pipeline_helper_async: debounced (recent attempt)') + callback(false) + return + end + _helper_start_debounce_ts = now + + local python = _resolve_python_exe(true) + if not python or python == '' then + _lua_log('attempt_start_pipeline_helper_async: no python executable available') + callback(false) + 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 args = { python, '-m', 'MPV.pipeline_helper', '--ipc', get_mpv_ipc_path(), '--timeout', '30' } + _lua_log('attempt_start_pipeline_helper_async: spawning helper') + + -- Spawn detached; don't wait for it here (async). + local ok = pcall(mp.command_native, { name = 'subprocess', args = args, cwd = cwd, detach = true }) + 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 }) + end + + if not ok then + _lua_log('attempt_start_pipeline_helper_async: spawn failed') + callback(false) + return + end + + -- Wait for helper to become ready in background (non-blocking). + local deadline = mp.get_time() + 3.0 + local timer + timer = mp.add_periodic_timer(0.1, function() + if _is_pipeline_helper_ready() then + timer:kill() + _lua_log('attempt_start_pipeline_helper_async: helper ready') + callback(true) + return + end + if mp.get_time() >= deadline then + timer:kill() + _lua_log('attempt_start_pipeline_helper_async: timeout waiting for ready') + callback(false) + end + end) +end + + local function ensure_pipeline_helper_running() - -- IMPORTANT: do NOT spawn Python from inside mpv. - -- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows - -- no-console flags; spawning here can flash a console window. - return _is_pipeline_helper_ready() and true or false + -- Check if helper is already running (don't spawn from here). + -- Auto-start is handled via explicit menu action only. + return _is_pipeline_helper_ready() end local _ipc_async_busy = false @@ -569,6 +648,8 @@ local function _run_helper_request_async(req, timeout_seconds, cb) end local function _run_helper_request_response(req, timeout_seconds) + -- Legacy synchronous wrapper for compatibility with run_pipeline_via_ipc_response. + -- TODO: Migrate all callers to async _run_helper_request_async and remove this. _last_ipc_error = '' if not ensure_pipeline_helper_running() then local rv = tostring(mp.get_property(PIPELINE_READY_PROP) or mp.get_property_native(PIPELINE_READY_PROP) or '') @@ -650,6 +731,46 @@ local function run_pipeline_via_ipc_response(pipeline_cmd, seeds, timeout_second return _run_helper_request_response(req, timeout_seconds) end +local function _url_can_direct_load(url) + -- Determine if a URL is safe to load directly via mpv loadfile (vs. requiring pipeline). + -- Complex streams like MPD/DASH manifests and ytdl URLs need the full pipeline. + url = tostring(url or ''); + local lower = url:lower() + + -- File paths and simple URLs are OK + if lower:match('^file://') or lower:match('^file:///') then return true end + if not lower:match('^https?://') and not lower:match('^rtmp') then return true end + + -- Block ytdl and other complex streams + if lower:match('youtube%.com') or lower:match('youtu%.be') then return false end + if lower:match('%.mpd%b()') or lower:match('%.mpd$') then return false end -- DASH manifest + if lower:match('manifest%.json') then return false end + if lower:match('twitch%.tv') or lower:match('youtube') then return false end + if lower:match('soundcloud%.com') or lower:match('bandcamp%.com') then return false end + if lower:match('spotify') or lower:match('tidal') then return false end + if lower:match('reddit%.com') or lower:match('tiktok%.com') then return false end + if lower:match('vimeo%.com') or lower:match('dailymotion%.com') then return false end + + -- Default: assume direct load is OK for plain HTTP(S) URLs + return true +end + +local function _try_direct_loadfile(url) + -- 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=false: loadfile command failed + if 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 + _lua_log('_try_direct_loadfile: attempting loadfile for ' .. url) + local ok_load = pcall(mp.commandv, 'loadfile', url, 'replace') + _lua_log('_try_direct_loadfile: loadfile result ok_load=' .. tostring(ok_load)) + return ok_load, ok_load -- Fallback attempted +end + local function quote_pipeline_arg(s) -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing. s = tostring(s or '') @@ -1483,7 +1604,6 @@ function FileState.new() return setmetatable({ url = nil, formats = nil, - formats_table = nil, -- back-compat alias }, FileState) end @@ -2187,16 +2307,22 @@ end -- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). -- Calls the callback with stdout on success or error message on failure. function M.run_pipeline(pipeline_cmd, seeds, cb) + _lua_log('M.run_pipeline called with cmd: ' .. tostring(pipeline_cmd)) cb = cb or function() end pipeline_cmd = trim(tostring(pipeline_cmd or '')) if pipeline_cmd == '' then + _lua_log('M.run_pipeline: empty command') cb(nil, 'empty pipeline command') return end ensure_mpv_ipc_server() - _run_pipeline_request_async(pipeline_cmd, seeds, 30, function(resp, err) + -- Use longer timeout for .mpv -url commands since they may involve downloading + local timeout_seconds = pipeline_cmd:match('%.mpv%s+%-url') and 120 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 + _lua_log('M.run_pipeline: success') cb(resp.stdout or '', nil) return end @@ -2279,6 +2405,7 @@ end -- Command: Load a URL via pipeline (Ctrl+Enter in prompt) function M.open_load_url_prompt() + _lua_log('open_load_url_prompt called') local menu_data = { type = LOAD_URL_MENU_TYPE, title = 'Load URL', @@ -2292,9 +2419,10 @@ function M.open_load_url_prompt() local json = utils.format_json(menu_data) if ensure_uosc_loaded() then + _lua_log('open_load_url_prompt: sending menu to uosc') mp.commandv('script-message-to', 'uosc', 'open-menu', json) else - _lua_log('menu: uosc not available; cannot open-menu') + _lua_log('menu: uosc not available; cannot open-menu for load-url') end end @@ -2602,52 +2730,131 @@ mp.register_script_message('medios-trim-run', function(json) end) mp.register_script_message('medios-load-url', function() + _lua_log('medios-load-url handler called') M.open_load_url_prompt() end) +mp.register_script_message('medios-start-helper', function() + -- Asynchronously start the pipeline helper without blocking the menu. + attempt_start_pipeline_helper_async(function(success) + if success then + mp.osd_message('Pipeline helper started', 2) + else + mp.osd_message('Failed to start pipeline helper (check logs)', 3) + end + end) +end) + mp.register_script_message('medios-load-url-event', function(json) + _lua_log('Load URL event handler called with: ' .. tostring(json or '')) local ok, event = pcall(utils.parse_json, json) if not ok or type(event) ~= 'table' then + _lua_log('Load URL: failed to parse JSON: ' .. tostring(json)) + mp.osd_message('Failed to parse URL', 2) + if ensure_uosc_loaded() then + mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) + end return end if event.type ~= 'search' then + _lua_log('Load URL: event type is ' .. tostring(event.type) .. ', expected search') + if ensure_uosc_loaded() then + mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) + end return end local url = trim(tostring(event.query or '')) if url == '' then + mp.osd_message('URL is empty', 2) + if ensure_uosc_loaded() then + mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) + end return end - ensure_mpv_ipc_server() - local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play' - M.run_pipeline(pipeline_cmd, nil, function(_, err) - if err then - mp.osd_message('Load URL failed: ' .. tostring(err), 3) - return - end + mp.osd_message('Loading URL...', 1) + _lua_log('Load URL: ' .. url) + + local function close_menu() + _lua_log('Load URL: closing menu') if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) - else - _lua_log('menu: uosc not available; cannot close-menu') end + end + + -- First, always try direct loadfile. This is the fastest path. + local can_direct = _url_can_direct_load(url) + _lua_log('Load URL: can_direct_load=' .. 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 succeeded') + mp.osd_message('URL loaded', 2) + close_menu() + return + else + _lua_log('Load URL: direct loadfile failed') + mp.osd_message('Load URL failed (direct)', 3) + close_menu() + return + end + end + + -- Complex streams (YouTube, DASH, etc.) need the pipeline helper. + _lua_log('Load URL: URL needs pipeline helper') + ensure_mpv_ipc_server() + local helper_ready = ensure_pipeline_helper_running() + _lua_log('Load URL: helper_ready=' .. tostring(helper_ready)) + + if not helper_ready then + mp.osd_message('Pipeline helper not running (try right-click menu)', 3) + close_menu() + return + end + + -- Use pipeline to download/prepare the URL + local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play' + _lua_log('Load URL: executing pipeline command: ' .. pipeline_cmd) + M.run_pipeline(pipeline_cmd, nil, function(resp, err) + _lua_log('Load URL: pipeline callback fired. resp=' .. tostring(resp) .. ', err=' .. tostring(err)) + if err then + _lua_log('Load URL: pipeline error: ' .. tostring(err)) + mp.osd_message('Load URL failed: ' .. tostring(err), 3) + close_menu() + return + end + _lua_log('Load URL: URL loaded successfully via pipeline') + mp.osd_message('URL loaded', 2) + close_menu() end) end) +end) -- Menu integration with UOSC function M.show_menu() + _lua_log('M.show_menu called') + local items = { + { title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" }, + { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" }, + { title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} }, + { title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" }, + { title = "Download", value = {"script-message-to", mp.get_script_name(), "medios-download-current"} }, + { title = "Change Format", value = {"script-message-to", mp.get_script_name(), "medios-change-format-current"} }, + } + + -- Only show "Start Helper" if helper is not running (conditional menu item) + if not _is_pipeline_helper_ready() then + table.insert(items, { title = "Start Helper", hint = "(for pipeline actions)", value = {"script-message-to", mp.get_script_name(), "medios-start-helper"} }) + end + local menu_data = { title = "Medios Macina", - items = { - { title = "Get Metadata", value = "script-binding medios-info", hint = "Ctrl+i" }, - { title = "Delete File", value = "script-binding medios-delete", hint = "Ctrl+Del" }, - { title = "Load URL", value = {"script-message-to", mp.get_script_name(), "medios-load-url"} }, - { title = "Cmd", value = {"script-message-to", mp.get_script_name(), "medios-open-cmd"}, hint = "Run quick commands (screenshot, trim, etc)" }, - { title = "Download", value = {"script-message-to", mp.get_script_name(), "medios-download-current"} }, - { title = "Change Format", value = {"script-message-to", mp.get_script_name(), "medios-change-format-current"} }, - } + items = items, } - + local json = utils.format_json(menu_data) if ensure_uosc_loaded() then mp.commandv('script-message-to', 'uosc', 'open-menu', json) diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index d2b7e46..0c107c7 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -7,6 +7,7 @@ This is the central hub for all Python-mpv IPC communication. The Lua script should use the Python CLI, which uses this module to manage mpv connections. """ +import ctypes import json import os import platform @@ -30,6 +31,23 @@ _LYRIC_LOG_FH: Optional[Any] = None _MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None +def _windows_pipe_available(path: str) -> bool: + """Check if a Windows named pipe is ready without raising.""" + if platform.system() != "Windows": + return False + if not path: + return False + try: + kernel32 = ctypes.windll.kernel32 + WaitNamedPipeW = kernel32.WaitNamedPipeW + WaitNamedPipeW.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32] + WaitNamedPipeW.restype = ctypes.c_bool + # Timeout 0 ensures we don't block. + return bool(WaitNamedPipeW(path, 0)) + except Exception: + return False + + def _windows_pythonw_exe(python_exe: Optional[str]) -> Optional[str]: """Return a pythonw.exe adjacent to python.exe if available (Windows only).""" if platform.system() != "Windows": @@ -970,6 +988,11 @@ class MPVIPCClient: try: if self.is_windows: # Windows named pipes + if not _windows_pipe_available(self.socket_path): + if not self.silent: + debug("Named pipe not available yet: %s" % self.socket_path) + return False + try: # Try to open the named pipe self.sock = open(self.socket_path, "r+b", buffering=0) diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 96d1268..239a042 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -562,26 +562,19 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]: } -def _helper_log_path() -> Path: - try: - d = _repo_root() / "Log" - d.mkdir(parents=True, exist_ok=True) - return d / "medeia-mpv-helper.log" - except Exception: - return Path(tempfile.gettempdir()) / "medeia-mpv-helper.log" - - def _append_helper_log(text: str) -> None: + """Log to database instead of file. This provides unified logging with rest of system.""" payload = (text or "").rstrip() if not payload: return try: - path = _helper_log_path() - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "a", encoding="utf-8", errors="replace") as fh: - fh.write(payload + "\n") + # Try database logging first (best practice: unified logging) + from SYS.database import log_to_db + log_to_db("INFO", "mpv", payload) except Exception: - return + # Fallback to stderr if database unavailable + import sys + print(f"[mpv-helper] {payload}", file=sys.stderr) def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: diff --git a/SYS/result_table.py b/SYS/result_table.py index 8c0a20d..7cf18ec 100644 --- a/SYS/result_table.py +++ b/SYS/result_table.py @@ -149,6 +149,7 @@ def _as_dict(item: Any) -> Optional[Dict[str, Any]]: Returns: Dictionary representation or None if conversion fails """ + if isinstance(item, dict): return item try: if hasattr(item, "__dict__"): @@ -171,6 +172,7 @@ def extract_store_value(item: Any) -> str: Returns: Store name as string (e.g., "hydrus", "local", "") if not found """ + data = _as_dict(item) or {} store = _get_first_dict_value( data, ["store", @@ -1149,17 +1151,17 @@ class Table: if (store_extracted and "store" not in visible_data and "table" not in visible_data and "source" not in visible_data): visible_data["store"] = store_extracted - except Exception: - from SYS.logger import logger - logger.exception("Failed to extract store value for item: %r", data) + except Exception as e: + from SYS.logger import log + log(f"Failed to extract store value for item: {data!r}. Error: {e}") try: ext_extracted = extract_ext_value(data) # Always ensure `ext` exists so priority_groups keeps a stable column. visible_data["ext"] = str(ext_extracted or "") - except Exception: - from SYS.logger import logger - logger.exception("Failed to extract ext value for item: %r", data) + except Exception as e: + from SYS.logger import log + log(f"Failed to extract ext value for item: {data!r}. Error: {e}") visible_data.setdefault("ext", "") try: @@ -1167,9 +1169,9 @@ class Table: if (size_extracted is not None and "size_bytes" not in visible_data and "size" not in visible_data): visible_data["size_bytes"] = size_extracted - except Exception: - from SYS.logger import logger - logger.exception("Failed to extract size bytes for item: %r", data) + except Exception as e: + from SYS.logger import log + log(f"Failed to extract size bytes for item: {data!r}. Error: {e}") # Handle extension separation for local files store_val = str(