This commit is contained in:
2026-02-03 17:14:11 -08:00
parent 1e0000ae19
commit cc19403087
6 changed files with 279 additions and 51 deletions

View File

@@ -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",

3
CLI.py
View File

@@ -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__})"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]:

View File

@@ -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(