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})" "filedot\\.(xyz|to|top)/([0-9a-zA-Z]{12})"
], ],
"regexp": "filedot\\.(xyz|to|top)/([0-9a-zA-Z]{12})", "regexp": "filedot\\.(xyz|to|top)/([0-9a-zA-Z]{12})",
"status": true "status": false
}, },
"filefactory": { "filefactory": {
"name": "filefactory", "name": "filefactory",

3
CLI.py
View File

@@ -31,6 +31,7 @@ if not os.environ.get("MM_DEBUG"):
except Exception: except Exception:
pass pass
import httpx
import json import json
import shlex import shlex
import sys 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) code = int(getattr(resp, "status_code", 0) or 0)
ok = 200 <= code < 500 ok = 200 <= code < 500
return ok, f"{url} (HTTP {code})" return ok, f"{url} (HTTP {code})"
except httpx.TimeoutException:
return False, f"{url} (timeout)"
except Exception as exc: except Exception as exc:
return False, f"{url} ({type(exc).__name__})" return False, f"{url} ({type(exc).__name__})"

View File

@@ -275,6 +275,12 @@ local opts = {
cli_path = nil -- Will be auto-detected if nil 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 function find_file_upwards(start_dir, relative_path, max_levels)
local dir = start_dir local dir = start_dir
local levels = max_levels or 6 local levels = max_levels or 6
@@ -397,6 +403,10 @@ local _last_ipc_error = ''
local _last_ipc_last_req_json = '' local _last_ipc_last_req_json = ''
local _last_ipc_last_resp_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 function _is_pipeline_helper_ready()
local ready = mp.get_property(PIPELINE_READY_PROP) local ready = mp.get_property(PIPELINE_READY_PROP)
if ready == nil or ready == '' then if ready == nil or ready == '' then
@@ -410,7 +420,7 @@ local function _is_pipeline_helper_ready()
return false return false
end 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) local n = tonumber(s)
if n and n > 1000000000 then if n and n > 1000000000 then
local now = (os and os.time) and os.time() or nil local now = (os and os.time) and os.time() or nil
@@ -424,7 +434,7 @@ local function _is_pipeline_helper_ready()
return age <= 10 return age <= 10
end end
-- If it's some other non-empty value, treat as ready. -- Non-empty value treated as ready
return true return true
end end
@@ -463,11 +473,80 @@ local function ensure_mpv_ipc_server()
return (now and now ~= '') and true or false return (now and now ~= '') and true or false
end 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() local function ensure_pipeline_helper_running()
-- IMPORTANT: do NOT spawn Python from inside mpv. -- Check if helper is already running (don't spawn from here).
-- The Python side (MPV.mpv_ipc) starts pipeline_helper.py using Windows -- Auto-start is handled via explicit menu action only.
-- no-console flags; spawning here can flash a console window. return _is_pipeline_helper_ready()
return _is_pipeline_helper_ready() and true or false
end end
local _ipc_async_busy = false local _ipc_async_busy = false
@@ -569,6 +648,8 @@ local function _run_helper_request_async(req, timeout_seconds, cb)
end end
local function _run_helper_request_response(req, timeout_seconds) 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 = '' _last_ipc_error = ''
if not ensure_pipeline_helper_running() then 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 '') 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) return _run_helper_request_response(req, timeout_seconds)
end 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) local function quote_pipeline_arg(s)
-- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing. -- Ensure URLs with special characters (e.g. &, #) survive pipeline parsing.
s = tostring(s or '') s = tostring(s or '')
@@ -1483,7 +1604,6 @@ function FileState.new()
return setmetatable({ return setmetatable({
url = nil, url = nil,
formats = nil, formats = nil,
formats_table = nil, -- back-compat alias
}, FileState) }, FileState)
end end
@@ -2187,16 +2307,22 @@ end
-- Run a Medeia pipeline command via the Python pipeline helper (IPC request/response). -- 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. -- Calls the callback with stdout on success or error message on failure.
function M.run_pipeline(pipeline_cmd, seeds, cb) function M.run_pipeline(pipeline_cmd, seeds, cb)
_lua_log('M.run_pipeline called with cmd: ' .. tostring(pipeline_cmd))
cb = cb or function() end cb = cb or function() end
pipeline_cmd = trim(tostring(pipeline_cmd or '')) pipeline_cmd = trim(tostring(pipeline_cmd or ''))
if pipeline_cmd == '' then if pipeline_cmd == '' then
_lua_log('M.run_pipeline: empty command')
cb(nil, 'empty pipeline command') cb(nil, 'empty pipeline command')
return return
end end
ensure_mpv_ipc_server() 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 if resp and resp.success then
_lua_log('M.run_pipeline: success')
cb(resp.stdout or '', nil) cb(resp.stdout or '', nil)
return return
end end
@@ -2279,6 +2405,7 @@ end
-- Command: Load a URL via pipeline (Ctrl+Enter in prompt) -- Command: Load a URL via pipeline (Ctrl+Enter in prompt)
function M.open_load_url_prompt() function M.open_load_url_prompt()
_lua_log('open_load_url_prompt called')
local menu_data = { local menu_data = {
type = LOAD_URL_MENU_TYPE, type = LOAD_URL_MENU_TYPE,
title = 'Load URL', title = 'Load URL',
@@ -2292,9 +2419,10 @@ function M.open_load_url_prompt()
local json = utils.format_json(menu_data) local json = utils.format_json(menu_data)
if ensure_uosc_loaded() then if ensure_uosc_loaded() then
_lua_log('open_load_url_prompt: sending menu to uosc')
mp.commandv('script-message-to', 'uosc', 'open-menu', json) mp.commandv('script-message-to', 'uosc', 'open-menu', json)
else else
_lua_log('menu: uosc not available; cannot open-menu') _lua_log('menu: uosc not available; cannot open-menu for load-url')
end end
end end
@@ -2602,50 +2730,129 @@ mp.register_script_message('medios-trim-run', function(json)
end) end)
mp.register_script_message('medios-load-url', function() mp.register_script_message('medios-load-url', function()
_lua_log('medios-load-url handler called')
M.open_load_url_prompt() M.open_load_url_prompt()
end) 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) 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) local ok, event = pcall(utils.parse_json, json)
if not ok or type(event) ~= 'table' then 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 return
end end
if event.type ~= 'search' then 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 return
end end
local url = trim(tostring(event.query or '')) local url = trim(tostring(event.query or ''))
if url == '' then 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 return
end end
ensure_mpv_ipc_server() mp.osd_message('Loading URL...', 1)
local pipeline_cmd = '.mpv -url ' .. quote_pipeline_arg(url) .. ' -play' _lua_log('Load URL: ' .. url)
M.run_pipeline(pipeline_cmd, nil, function(_, err)
if err then local function close_menu()
mp.osd_message('Load URL failed: ' .. tostring(err), 3) _lua_log('Load URL: closing menu')
return
end
if ensure_uosc_loaded() then if ensure_uosc_loaded() then
mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE)
else
_lua_log('menu: uosc not available; cannot close-menu')
end 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) end)
end)
-- Menu integration with UOSC -- Menu integration with UOSC
function M.show_menu() 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 = { local menu_data = {
title = "Medios Macina", title = "Medios Macina",
items = { items = 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"} },
}
} }
local json = utils.format_json(menu_data) local json = utils.format_json(menu_data)

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. should use the Python CLI, which uses this module to manage mpv connections.
""" """
import ctypes
import json import json
import os import os
import platform import platform
@@ -30,6 +31,23 @@ _LYRIC_LOG_FH: Optional[Any] = None
_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = 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]: def _windows_pythonw_exe(python_exe: Optional[str]) -> Optional[str]:
"""Return a pythonw.exe adjacent to python.exe if available (Windows only).""" """Return a pythonw.exe adjacent to python.exe if available (Windows only)."""
if platform.system() != "Windows": if platform.system() != "Windows":
@@ -970,6 +988,11 @@ class MPVIPCClient:
try: try:
if self.is_windows: if self.is_windows:
# Windows named pipes # 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:
# Try to open the named pipe # Try to open the named pipe
self.sock = open(self.socket_path, "r+b", buffering=0) 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: 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() payload = (text or "").rstrip()
if not payload: if not payload:
return return
try: try:
path = _helper_log_path() # Try database logging first (best practice: unified logging)
path.parent.mkdir(parents=True, exist_ok=True) from SYS.database import log_to_db
with open(path, "a", encoding="utf-8", errors="replace") as fh: log_to_db("INFO", "mpv", payload)
fh.write(payload + "\n")
except Exception: 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]: 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: Returns:
Dictionary representation or None if conversion fails Dictionary representation or None if conversion fails
""" """
if isinstance(item, dict):
return item return item
try: try:
if hasattr(item, "__dict__"): if hasattr(item, "__dict__"):
@@ -171,6 +172,7 @@ def extract_store_value(item: Any) -> str:
Returns: Returns:
Store name as string (e.g., "hydrus", "local", "") if not found Store name as string (e.g., "hydrus", "local", "") if not found
""" """
data = _as_dict(item) or {}
store = _get_first_dict_value( store = _get_first_dict_value(
data, data,
["store", ["store",
@@ -1149,17 +1151,17 @@ class Table:
if (store_extracted and "store" not in visible_data if (store_extracted and "store" not in visible_data
and "table" not in visible_data and "source" not in visible_data): and "table" not in visible_data and "source" not in visible_data):
visible_data["store"] = store_extracted visible_data["store"] = store_extracted
except Exception: except Exception as e:
from SYS.logger import logger from SYS.logger import log
logger.exception("Failed to extract store value for item: %r", data) log(f"Failed to extract store value for item: {data!r}. Error: {e}")
try: try:
ext_extracted = extract_ext_value(data) ext_extracted = extract_ext_value(data)
# Always ensure `ext` exists so priority_groups keeps a stable column. # Always ensure `ext` exists so priority_groups keeps a stable column.
visible_data["ext"] = str(ext_extracted or "") visible_data["ext"] = str(ext_extracted or "")
except Exception: except Exception as e:
from SYS.logger import logger from SYS.logger import log
logger.exception("Failed to extract ext value for item: %r", data) log(f"Failed to extract ext value for item: {data!r}. Error: {e}")
visible_data.setdefault("ext", "") visible_data.setdefault("ext", "")
try: try:
@@ -1167,9 +1169,9 @@ class Table:
if (size_extracted is not None and "size_bytes" not in visible_data if (size_extracted is not None and "size_bytes" not in visible_data
and "size" not in visible_data): and "size" not in visible_data):
visible_data["size_bytes"] = size_extracted visible_data["size_bytes"] = size_extracted
except Exception: except Exception as e:
from SYS.logger import logger from SYS.logger import log
logger.exception("Failed to extract size bytes for item: %r", data) log(f"Failed to extract size bytes for item: {data!r}. Error: {e}")
# Handle extension separation for local files # Handle extension separation for local files
store_val = str( store_val = str(