From f8c98b39bd6c16407a49488755e1eb99cbd16f68 Mon Sep 17 00:00:00 2001 From: Nose Date: Sat, 21 Mar 2026 22:56:37 -0700 Subject: [PATCH] update --- MPV/LUA/main.lua | 104 +++++++++++++++--- MPV/pipeline_helper.py | 237 ++++++++++++++++++++++++++++++----------- Store/registry.py | 20 +++- cmdlet/add_file.py | 25 +++++ 4 files changed, 304 insertions(+), 82 deletions(-) diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 16a1b65..faebe5b 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -4,7 +4,7 @@ local msg = require 'mp.msg' local M = {} -local MEDEIA_LUA_VERSION = '2026-03-21.1' +local MEDEIA_LUA_VERSION = '2026-03-21.3' -- Expose a tiny breadcrumb for debugging which script version is loaded. pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION) @@ -748,6 +748,7 @@ 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 _pick_folder_windows local function _normalize_store_name(store) store = trim(tostring(store or '')) @@ -755,6 +756,22 @@ local function _normalize_store_name(store) return trim(store) end +local function _is_cached_store_name(store) + local needle = _normalize_store_name(store) + if needle == '' then + return false + end + if type(_cached_store_names) ~= 'table' then + return false + end + for _, name in ipairs(_cached_store_names) do + if _normalize_store_name(name) == needle then + return true + end + end + return false +end + local function _get_script_opts_dir() local dir = nil pcall(function() @@ -863,7 +880,7 @@ local function _is_pipeline_helper_ready() helper_version = mp.get_property_native('user-data/medeia-pipeline-helper-version') end helper_version = tostring(helper_version or '') - if helper_version ~= '2026-03-22.2' then + if helper_version ~= '2026-03-22.4' then return false end @@ -930,7 +947,7 @@ local function _helper_ready_diagnostics() end return 'ready=' .. tostring(ready or '') .. ' helper_version=' .. tostring(helper_version or '') - .. ' required_version=2026-03-22.2' + .. ' required_version=2026-03-22.4' .. ' last_value=' .. tostring(_helper_ready_last_value or '') .. ' last_seen_age=' .. tostring(age) end @@ -973,11 +990,28 @@ 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 + local helper_start_state = M._helper_start_state or { inflight = false, callbacks = {} } + M._helper_start_state = helper_start_state + + local function finish(success) + local callbacks = helper_start_state.callbacks or {} + helper_start_state.callbacks = {} + helper_start_state.inflight = false + for _, cb in ipairs(callbacks) do + pcall(cb, success) + end + end if _is_pipeline_helper_ready() then callback(true) return end + + if helper_start_state.inflight then + table.insert(helper_start_state.callbacks, callback) + _lua_log('attempt_start_pipeline_helper_async: join existing startup') + return + end -- Debounce: don't spawn multiple helpers in quick succession local now = mp.get_time() @@ -987,6 +1021,8 @@ local function attempt_start_pipeline_helper_async(callback) return end _helper_start_debounce_ts = now + helper_start_state.inflight = true + helper_start_state.callbacks = { callback } -- Clear any stale ready heartbeat from an earlier helper instance before spawning. pcall(mp.set_property, PIPELINE_READY_PROP, '') @@ -997,7 +1033,7 @@ local function attempt_start_pipeline_helper_async(callback) 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) + finish(false) return end @@ -1015,7 +1051,7 @@ local function attempt_start_pipeline_helper_async(callback) if not ok then _lua_log('attempt_start_pipeline_helper_async: spawn failed final=' .. tostring(detail or _describe_subprocess_result(result))) - callback(false) + finish(false) return end @@ -1026,13 +1062,13 @@ local function attempt_start_pipeline_helper_async(callback) if _is_pipeline_helper_ready() then timer:kill() _lua_log('attempt_start_pipeline_helper_async: helper ready') - callback(true) + finish(true) return end if mp.get_time() >= deadline then timer:kill() _lua_log('attempt_start_pipeline_helper_async: timeout waiting for ready') - callback(false) + finish(false) end end) end @@ -1954,6 +1990,7 @@ local function _start_screenshot_store_save(store, out_path, tags) return false end + local is_named_store = _is_cached_store_name(store) local tag_list = _normalize_tag_list(tags) local screenshot_url = trim(tostring((_current_url_for_web_actions and _current_url_for_web_actions()) or mp.get_property(CURRENT_WEB_URL_PROP) or '')) if screenshot_url == '' or not screenshot_url:match('^https?://') then @@ -1961,29 +1998,35 @@ local function _start_screenshot_store_save(store, out_path, tags) end local cmd = 'add-file -store ' .. quote_pipeline_arg(store) .. ' -path ' .. quote_pipeline_arg(out_path) - _set_selected_store(store) + if screenshot_url ~= '' then + cmd = cmd .. ' -url ' .. quote_pipeline_arg(screenshot_url) + end + if is_named_store then + _set_selected_store(store) + end local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or '' if #tag_list > 0 then local tag_string = table.concat(tag_list, ',') cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string) end - if screenshot_url ~= '' then - cmd = cmd .. ' | add-url ' .. quote_pipeline_arg(screenshot_url) - end + + local queue_target = is_named_store and ('store ' .. store) or 'folder' + local success_text = is_named_store and ('Screenshot saved to store: ' .. store .. tag_suffix) or ('Screenshot saved to folder' .. tag_suffix) + local failure_text = is_named_store and 'Screenshot upload failed' or 'Screenshot save failed' _lua_log('screenshot-save: queueing repl pipeline cmd=' .. cmd) return _queue_pipeline_in_repl( cmd, - 'Queued in REPL: screenshot -> ' .. store .. tag_suffix, + 'Queued in REPL: screenshot -> ' .. queue_target .. tag_suffix, 'Screenshot queue failed', 'screenshot-save', { kind = 'mpv-screenshot', mpv_notify = { - success_text = 'Screenshot saved to store: ' .. store .. tag_suffix, - failure_text = 'Screenshot upload failed', + success_text = success_text, + failure_text = failure_text, duration_ms = 3500, }, } @@ -2049,6 +2092,14 @@ local function _open_store_picker_for_pending_screenshot() local selected = _get_selected_store() local items = {} + if _is_windows() then + items[#items + 1] = { + title = 'Pick folder…', + hint = 'Save screenshot to a local folder', + value = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-pick-path', '{}' }, + } + end + if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then for _, name in ipairs(_cached_store_names) do name = trim(tostring(name or '')) @@ -2061,7 +2112,7 @@ local function _open_store_picker_for_pending_screenshot() } end end - else + elseif #items == 0 then items[#items + 1] = { title = 'No stores found', hint = 'Configure stores in config.conf', @@ -2149,6 +2200,9 @@ local function _capture_screenshot() local function dispatch_screenshot_save() local store_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 local selected_store = _normalize_store_name(_get_selected_store()) + if not _is_cached_store_name(selected_store) then + selected_store = '' + end if store_count > 1 then _pending_screenshot = { path = out_path } @@ -2200,6 +2254,24 @@ mp.register_script_message('medeia-image-screenshot-pick-store', function(json) _open_screenshot_tag_prompt(store, out_path) end) +mp.register_script_message('medeia-image-screenshot-pick-path', function() + if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path then + return + end + if not _is_windows() then + mp.osd_message('Folder picker is Windows-only', 4) + return + end + + local folder = _pick_folder_windows() + if not folder or folder == '' then + return + end + + local out_path = tostring(_pending_screenshot.path or '') + _open_screenshot_tag_prompt(folder, out_path) +end) + mp.register_script_message('medeia-image-screenshot-tags-search', function(query) _apply_screenshot_tag_query(query) end) @@ -2591,7 +2663,7 @@ local function _extract_store_hash(target) return nil end -local function _pick_folder_windows() +_pick_folder_windows = function() -- Native folder picker via PowerShell + WinForms. local ps = [[Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = 'Select download folder'; $d.ShowNewFolderButton = $true; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }]] local res = utils.subprocess({ diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index f1432d2..ed24d46 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -20,7 +20,7 @@ This helper is intentionally minimal: one request at a time, last-write-wins. from __future__ import annotations -MEDEIA_MPV_HELPER_VERSION = "2026-03-22.2" +MEDEIA_MPV_HELPER_VERSION = "2026-03-22.4" import argparse import json @@ -194,20 +194,35 @@ def _append_prefixed_log_lines(prefix: str, text: Any, *, max_lines: int = 40) - break -def _start_ready_heartbeat(ipc_path: str, stop_event: threading.Event) -> threading.Thread: +def _start_ready_heartbeat( + ipc_path: str, + stop_event: threading.Event, + mark_alive: Optional[Callable[[str], None]] = None, + note_ipc_unavailable: Optional[Callable[[str], None]] = None, +) -> 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 + was_disconnected = hb_client.sock is None + if was_disconnected: + if not hb_client.connect(): + if note_ipc_unavailable is not None: + note_ipc_unavailable("heartbeat-connect") + stop_event.wait(0.25) + continue + if mark_alive is not None: + mark_alive("heartbeat-connect") hb_client.send_command_no_wait( ["set_property_string", READY_PROP, str(int(time.time()))] ) + if mark_alive is not None: + mark_alive("heartbeat-send") except Exception: + if note_ipc_unavailable is not None: + note_ipc_unavailable("heartbeat-send") try: hb_client.disconnect() except Exception: @@ -1207,6 +1222,8 @@ def _start_request_poll_loop( ipc_path: str, stop_event: threading.Event, handle_request: Callable[[Any, str], bool], + mark_alive: Optional[Callable[[str], None]] = None, + note_ipc_unavailable: Optional[Callable[[str], None]] = None, ) -> threading.Thread: """Poll the request property on a separate IPC connection. @@ -1219,12 +1236,20 @@ def _start_request_poll_loop( poll_client = MPVIPCClient(socket_path=ipc_path, timeout=0.75, silent=True) while not stop_event.is_set(): try: - if poll_client.sock is None and not poll_client.connect(): - stop_event.wait(0.10) - continue + was_disconnected = poll_client.sock is None + if was_disconnected: + if not poll_client.connect(): + if note_ipc_unavailable is not None: + note_ipc_unavailable("request-poll-connect") + stop_event.wait(0.10) + continue + if mark_alive is not None: + mark_alive("request-poll-connect") resp = poll_client.send_command(["get_property", REQUEST_PROP]) if not resp: + if note_ipc_unavailable is not None: + note_ipc_unavailable("request-poll-read") try: poll_client.disconnect() except Exception: @@ -1232,10 +1257,14 @@ def _start_request_poll_loop( stop_event.wait(0.10) continue + if mark_alive is not None: + mark_alive("request-poll-read") if resp.get("error") == "success": handle_request(resp.get("data"), "poll") stop_event.wait(0.05) except Exception: + if note_ipc_unavailable is not None: + note_ipc_unavailable("request-poll-exception") try: poll_client.disconnect() except Exception: @@ -1386,9 +1415,48 @@ def main(argv: Optional[list[str]] = None) -> int: seen_request_ttl_seconds = 180.0 request_processing_lock = threading.Lock() command_client_lock = threading.Lock() + stop_event = threading.Event() + ipc_loss_grace_seconds = 4.0 + ipc_lost_since: Optional[float] = None + ipc_connected_once = False + shutdown_reason = "" + shutdown_reason_lock = threading.Lock() _send_helper_command = lambda _command, _label='': False _publish_store_choices_cached_property = lambda _choices: None + def _request_shutdown(reason: str) -> None: + nonlocal shutdown_reason + message = str(reason or "").strip() or "unknown" + with shutdown_reason_lock: + if shutdown_reason: + return + shutdown_reason = message + _append_helper_log(f"[helper] shutdown requested: {message}") + stop_event.set() + + def _mark_ipc_alive(source: str = "") -> None: + nonlocal ipc_lost_since, ipc_connected_once + if ipc_lost_since is not None and source: + _append_helper_log(f"[helper] ipc restored via {source}") + ipc_connected_once = True + ipc_lost_since = None + + def _note_ipc_unavailable(source: str) -> None: + nonlocal ipc_lost_since + if stop_event.is_set() or not ipc_connected_once: + return + now = time.time() + if ipc_lost_since is None: + ipc_lost_since = now + _append_helper_log( + f"[helper] ipc unavailable via {source}; waiting {ipc_loss_grace_seconds:.1f}s for reconnect" + ) + return + if (now - ipc_lost_since) >= ipc_loss_grace_seconds: + _request_shutdown( + f"mpv ipc unavailable for {now - ipc_lost_since:.2f}s via {source}" + ) + def _write_error_log(text: str, *, req_id: str) -> Optional[str]: try: error_log_dir.mkdir(parents=True, exist_ok=True) @@ -1593,6 +1661,7 @@ def main(argv: Optional[list[str]] = None) -> int: while True: try: if client.connect(): + _mark_ipc_alive("startup-connect") break except Exception as exc: last_connect_error = f"{type(exc).__name__}: {exc}" @@ -1611,26 +1680,32 @@ def main(argv: Optional[list[str]] = None) -> int: def _send_helper_command(command: Any, label: str = "") -> bool: with command_client_lock: try: - if command_client.sock is None and not command_client.connect(): - _append_helper_log( - f"[helper-ipc] connect failed label={label or '?'}" - ) - return False + if command_client.sock is None: + if not command_client.connect(): + _append_helper_log( + f"[helper-ipc] connect failed label={label or '?'}" + ) + _note_ipc_unavailable(f"helper-command-connect:{label or '?' }") + return False + _mark_ipc_alive(f"helper-command-connect:{label or '?'}") rid = command_client.send_command_no_wait(command) if rid is None: _append_helper_log( f"[helper-ipc] send failed label={label or '?'}" ) + _note_ipc_unavailable(f"helper-command-send:{label or '?'}") try: command_client.disconnect() except Exception: pass return False + _mark_ipc_alive(f"helper-command-send:{label or '?'}") return True except Exception as exc: _append_helper_log( f"[helper-ipc] exception label={label or '?'} error={type(exc).__name__}: {exc}" ) + _note_ipc_unavailable(f"helper-command-exception:{label or '?'}") try: command_client.disconnect() except Exception: @@ -1740,9 +1815,19 @@ def main(argv: Optional[list[str]] = None) -> int: except Exception: pass - heartbeat_stop = threading.Event() - _start_ready_heartbeat(str(args.ipc), heartbeat_stop) - _start_request_poll_loop(str(args.ipc), heartbeat_stop, _process_request) + _start_ready_heartbeat( + str(args.ipc), + stop_event, + _mark_ipc_alive, + _note_ipc_unavailable, + ) + _start_request_poll_loop( + str(args.ipc), + stop_event, + _process_request, + _mark_ipc_alive, + _note_ipc_unavailable, + ) # 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). @@ -1824,67 +1909,91 @@ def main(argv: Optional[list[str]] = None) -> int: except Exception: pass - while True: - msg = client.read_message(timeout=0.25) - if msg is None: - # Keep READY fresh even when idle (Lua may clear it on timeouts). - _touch_ready() - time.sleep(0.02) - continue + try: + while not stop_event.is_set(): + msg = client.read_message(timeout=0.25) + if msg is None: + if client.sock is None: + _note_ipc_unavailable("main-read") + else: + _mark_ipc_alive("main-idle") + # Keep READY fresh even when idle (Lua may clear it on timeouts). + _touch_ready() + time.sleep(0.02) + continue - if msg.get("event") == "__eof__": - try: - _flush_mpv_repeat() - except Exception: - pass - heartbeat_stop.set() - return 0 + _mark_ipc_alive("main-read") - if msg.get("event") == "log-message": - try: - level = str(msg.get("level") or "") - prefix = str(msg.get("prefix") or "") - text = str(msg.get("text") or "").rstrip() + if msg.get("event") == "__eof__": + _request_shutdown("mpv closed ipc stream") + break - if not text: - continue + if msg.get("event") == "log-message": + try: + level = str(msg.get("level") or "") + prefix = str(msg.get("prefix") or "") + text = str(msg.get("text") or "").rstrip() - # Filter excessive noise unless debug is enabled. - if not debug_enabled: - lower_prefix = prefix.lower() - if "quic" in lower_prefix and "DEBUG:" in text: + if not text: continue - # Suppress progress-bar style lines (keep true errors). - if ("ETA" in text or "%" in text) and ("ERROR:" not in text - and "WARNING:" not in text): - # Typical yt-dlp progress bar line. - if text.lstrip().startswith("["): + + # Filter excessive noise unless debug is enabled. + if not debug_enabled: + lower_prefix = prefix.lower() + if "quic" in lower_prefix and "DEBUG:" in text: continue + # Suppress progress-bar style lines (keep true errors). + if ("ETA" in text or "%" in text) and ("ERROR:" not in text + and "WARNING:" not in text): + # Typical yt-dlp progress bar line. + if text.lstrip().startswith("["): + continue - line = f"[mpv {level}] {prefix} {text}".strip() + line = f"[mpv {level}] {prefix} {text}".strip() - now = time.time() - if last_mpv_line == line and (now - last_mpv_ts) < 2.0: - last_mpv_count += 1 + now = time.time() + if last_mpv_line == line and (now - last_mpv_ts) < 2.0: + last_mpv_count += 1 + last_mpv_ts = now + continue + + _flush_mpv_repeat() + last_mpv_line = line + last_mpv_count = 1 last_mpv_ts = now - continue + _append_helper_log(line) + except Exception: + pass + continue - _flush_mpv_repeat() - last_mpv_line = line - last_mpv_count = 1 - last_mpv_ts = now - _append_helper_log(line) + if msg.get("event") != "property-change": + continue + + if msg.get("id") != OBS_ID_REQUEST: + continue + + _process_request(msg.get("data"), "observe") + finally: + stop_event.set() + try: + _flush_mpv_repeat() + except Exception: + pass + if shutdown_reason: + try: + _append_helper_log(f"[helper] exiting reason={shutdown_reason}") except Exception: pass - continue + try: + command_client.disconnect() + except Exception: + pass + try: + client.disconnect() + except Exception: + pass - if msg.get("event") != "property-change": - continue - - if msg.get("id") != OBS_ID_REQUEST: - continue - - _process_request(msg.get("data"), "observe") + return 0 if __name__ == "__main__": diff --git a/Store/registry.py b/Store/registry.py index 08e9096..81748c8 100644 --- a/Store/registry.py +++ b/Store/registry.py @@ -353,19 +353,35 @@ def list_configured_backend_names(config: Optional[Dict[str, Any]]) -> list[str] without triggering backend initialization (which may perform network calls). Behaviour: - - For each configured store type, returns the per-instance NAME override (case-insensitive) - when present, otherwise the instance key. + - Only includes store types that map to a discovered save backend class. + - Skips folder/provider-only/unknown entries so UI pickers do not surface + non-ingest destinations such as provider helpers. + - For each configured store type, returns the per-instance NAME override + (case-insensitive) when present, otherwise the instance key. """ try: store_cfg = (config or {}).get("store") or {} if not isinstance(store_cfg, dict): return [] + classes_by_type = _discover_store_classes() names: list[str] = [] for raw_store_type, instances in store_cfg.items(): if not isinstance(instances, dict): continue + store_type = _normalize_store_type(str(raw_store_type)) + if store_type == "folder" or store_type in _PROVIDER_ONLY_STORE_NAMES: + continue + + store_cls = classes_by_type.get(store_type) + if store_cls is None: + continue + for instance_name, instance_config in instances.items(): + try: + _build_kwargs(store_cls, str(instance_name), instance_config) + except Exception: + continue if isinstance(instance_config, dict): override_name = _get_case_insensitive(dict(instance_config), "NAME") if override_name: diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index ac41b84..fa2d4e9 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -180,6 +180,7 @@ class Add_File(Cmdlet): arg=[ SharedArgs.PATH, SharedArgs.STORE, + SharedArgs.URL, SharedArgs.PROVIDER, CmdletArg( name="delete", @@ -220,6 +221,7 @@ class Add_File(Cmdlet): path_arg = parsed.get("path") location = parsed.get("store") + source_url_arg = parsed.get("url") provider_name = parsed.get("provider") delete_after = parsed.get("delete", False) @@ -519,6 +521,29 @@ class Add_File(Cmdlet): for idx, item in enumerate(items_to_process, 1): pipe_obj = coerce_to_pipe_object(item, path_arg) + if source_url_arg: + try: + from SYS.metadata import normalize_urls + + cli_urls = [u.strip() for u in str(source_url_arg).split(",") if u and u.strip()] + merged_urls: List[str] = [] + + if isinstance(getattr(pipe_obj, "extra", None), dict): + existing_url = pipe_obj.extra.get("url") + if isinstance(existing_url, list): + merged_urls.extend(str(u) for u in existing_url if u) + elif isinstance(existing_url, str) and existing_url.strip(): + merged_urls.append(existing_url.strip()) + else: + pipe_obj.extra = {} + + merged_urls.extend(cli_urls) + merged_urls = normalize_urls(merged_urls) + if merged_urls: + pipe_obj.extra["url"] = merged_urls + except Exception: + pass + try: label = pipe_obj.title if not label and pipe_obj.path: