This commit is contained in:
2026-03-21 22:56:37 -07:00
parent b183167a64
commit f8c98b39bd
4 changed files with 304 additions and 82 deletions

View File

@@ -4,7 +4,7 @@ local msg = require 'mp.msg'
local M = {} 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. -- Expose a tiny breadcrumb for debugging which script version is loaded.
pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION) 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 _store_status_hint_for_url
local _refresh_current_store_url_status local _refresh_current_store_url_status
local _skip_next_store_check_url = '' local _skip_next_store_check_url = ''
local _pick_folder_windows
local function _normalize_store_name(store) local function _normalize_store_name(store)
store = trim(tostring(store or '')) store = trim(tostring(store or ''))
@@ -755,6 +756,22 @@ local function _normalize_store_name(store)
return trim(store) return trim(store)
end 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 function _get_script_opts_dir()
local dir = nil local dir = nil
pcall(function() pcall(function()
@@ -863,7 +880,7 @@ local function _is_pipeline_helper_ready()
helper_version = mp.get_property_native('user-data/medeia-pipeline-helper-version') helper_version = mp.get_property_native('user-data/medeia-pipeline-helper-version')
end end
helper_version = tostring(helper_version or '') helper_version = tostring(helper_version or '')
if helper_version ~= '2026-03-22.2' then if helper_version ~= '2026-03-22.4' then
return false return false
end end
@@ -930,7 +947,7 @@ local function _helper_ready_diagnostics()
end end
return 'ready=' .. tostring(ready or '') return 'ready=' .. tostring(ready or '')
.. ' helper_version=' .. tostring(helper_version 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_value=' .. tostring(_helper_ready_last_value or '')
.. ' last_seen_age=' .. tostring(age) .. ' last_seen_age=' .. tostring(age)
end end
@@ -973,11 +990,28 @@ end
local function attempt_start_pipeline_helper_async(callback) local function attempt_start_pipeline_helper_async(callback)
-- Async version: spawn helper without blocking UI. Calls callback(success) when done. -- Async version: spawn helper without blocking UI. Calls callback(success) when done.
callback = callback or function() end 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 if _is_pipeline_helper_ready() then
callback(true) callback(true)
return return
end 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 -- Debounce: don't spawn multiple helpers in quick succession
local now = mp.get_time() local now = mp.get_time()
@@ -987,6 +1021,8 @@ local function attempt_start_pipeline_helper_async(callback)
return return
end end
_helper_start_debounce_ts = now _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. -- Clear any stale ready heartbeat from an earlier helper instance before spawning.
pcall(mp.set_property, PIPELINE_READY_PROP, '') 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) local python = _resolve_python_exe(true)
if not python or python == '' then if not python or python == '' then
_lua_log('attempt_start_pipeline_helper_async: no python executable available') _lua_log('attempt_start_pipeline_helper_async: no python executable available')
callback(false) finish(false)
return return
end end
@@ -1015,7 +1051,7 @@ local function attempt_start_pipeline_helper_async(callback)
if not ok then if not ok then
_lua_log('attempt_start_pipeline_helper_async: spawn failed final=' .. tostring(detail or _describe_subprocess_result(result))) _lua_log('attempt_start_pipeline_helper_async: spawn failed final=' .. tostring(detail or _describe_subprocess_result(result)))
callback(false) finish(false)
return return
end end
@@ -1026,13 +1062,13 @@ local function attempt_start_pipeline_helper_async(callback)
if _is_pipeline_helper_ready() then if _is_pipeline_helper_ready() then
timer:kill() timer:kill()
_lua_log('attempt_start_pipeline_helper_async: helper ready') _lua_log('attempt_start_pipeline_helper_async: helper ready')
callback(true) finish(true)
return return
end end
if mp.get_time() >= deadline then if mp.get_time() >= deadline then
timer:kill() timer:kill()
_lua_log('attempt_start_pipeline_helper_async: timeout waiting for ready') _lua_log('attempt_start_pipeline_helper_async: timeout waiting for ready')
callback(false) finish(false)
end end
end) end)
end end
@@ -1954,6 +1990,7 @@ local function _start_screenshot_store_save(store, out_path, tags)
return false return false
end end
local is_named_store = _is_cached_store_name(store)
local tag_list = _normalize_tag_list(tags) 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 '')) 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 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 end
local cmd = 'add-file -store ' .. quote_pipeline_arg(store) local cmd = 'add-file -store ' .. quote_pipeline_arg(store)
.. ' -path ' .. quote_pipeline_arg(out_path) .. ' -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 '' local tag_suffix = (#tag_list > 0) and (' | tags: ' .. tostring(#tag_list)) or ''
if #tag_list > 0 then if #tag_list > 0 then
local tag_string = table.concat(tag_list, ',') local tag_string = table.concat(tag_list, ',')
cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string) cmd = cmd .. ' | add-tag ' .. quote_pipeline_arg(tag_string)
end end
if screenshot_url ~= '' then
cmd = cmd .. ' | add-url ' .. quote_pipeline_arg(screenshot_url) local queue_target = is_named_store and ('store ' .. store) or 'folder'
end 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) _lua_log('screenshot-save: queueing repl pipeline cmd=' .. cmd)
return _queue_pipeline_in_repl( return _queue_pipeline_in_repl(
cmd, cmd,
'Queued in REPL: screenshot -> ' .. store .. tag_suffix, 'Queued in REPL: screenshot -> ' .. queue_target .. tag_suffix,
'Screenshot queue failed', 'Screenshot queue failed',
'screenshot-save', 'screenshot-save',
{ {
kind = 'mpv-screenshot', kind = 'mpv-screenshot',
mpv_notify = { mpv_notify = {
success_text = 'Screenshot saved to store: ' .. store .. tag_suffix, success_text = success_text,
failure_text = 'Screenshot upload failed', failure_text = failure_text,
duration_ms = 3500, duration_ms = 3500,
}, },
} }
@@ -2049,6 +2092,14 @@ local function _open_store_picker_for_pending_screenshot()
local selected = _get_selected_store() local selected = _get_selected_store()
local items = {} 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 if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then
for _, name in ipairs(_cached_store_names) do for _, name in ipairs(_cached_store_names) do
name = trim(tostring(name or '')) name = trim(tostring(name or ''))
@@ -2061,7 +2112,7 @@ local function _open_store_picker_for_pending_screenshot()
} }
end end
end end
else elseif #items == 0 then
items[#items + 1] = { items[#items + 1] = {
title = 'No stores found', title = 'No stores found',
hint = 'Configure stores in config.conf', hint = 'Configure stores in config.conf',
@@ -2149,6 +2200,9 @@ local function _capture_screenshot()
local function dispatch_screenshot_save() local function dispatch_screenshot_save()
local store_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0 local store_count = (type(_cached_store_names) == 'table') and #_cached_store_names or 0
local selected_store = _normalize_store_name(_get_selected_store()) 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 if store_count > 1 then
_pending_screenshot = { path = out_path } _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) _open_screenshot_tag_prompt(store, out_path)
end) 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) mp.register_script_message('medeia-image-screenshot-tags-search', function(query)
_apply_screenshot_tag_query(query) _apply_screenshot_tag_query(query)
end) end)
@@ -2591,7 +2663,7 @@ local function _extract_store_hash(target)
return nil return nil
end end
local function _pick_folder_windows() _pick_folder_windows = function()
-- Native folder picker via PowerShell + WinForms. -- 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 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({ local res = utils.subprocess({

View File

@@ -20,7 +20,7 @@ This helper is intentionally minimal: one request at a time, last-write-wins.
from __future__ import annotations from __future__ import annotations
MEDEIA_MPV_HELPER_VERSION = "2026-03-22.2" MEDEIA_MPV_HELPER_VERSION = "2026-03-22.4"
import argparse import argparse
import json import json
@@ -194,20 +194,35 @@ def _append_prefixed_log_lines(prefix: str, text: Any, *, max_lines: int = 40) -
break 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.""" """Keep READY_PROP fresh even when the main loop blocks on Windows pipes."""
def _heartbeat_loop() -> None: def _heartbeat_loop() -> None:
hb_client = MPVIPCClient(socket_path=ipc_path, timeout=0.5, silent=True) hb_client = MPVIPCClient(socket_path=ipc_path, timeout=0.5, silent=True)
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
if hb_client.sock is None and not hb_client.connect(): was_disconnected = hb_client.sock is None
stop_event.wait(0.25) if was_disconnected:
continue 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( hb_client.send_command_no_wait(
["set_property_string", READY_PROP, str(int(time.time()))] ["set_property_string", READY_PROP, str(int(time.time()))]
) )
if mark_alive is not None:
mark_alive("heartbeat-send")
except Exception: except Exception:
if note_ipc_unavailable is not None:
note_ipc_unavailable("heartbeat-send")
try: try:
hb_client.disconnect() hb_client.disconnect()
except Exception: except Exception:
@@ -1207,6 +1222,8 @@ def _start_request_poll_loop(
ipc_path: str, ipc_path: str,
stop_event: threading.Event, stop_event: threading.Event,
handle_request: Callable[[Any, str], bool], handle_request: Callable[[Any, str], bool],
mark_alive: Optional[Callable[[str], None]] = None,
note_ipc_unavailable: Optional[Callable[[str], None]] = None,
) -> threading.Thread: ) -> threading.Thread:
"""Poll the request property on a separate IPC connection. """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) poll_client = MPVIPCClient(socket_path=ipc_path, timeout=0.75, silent=True)
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
if poll_client.sock is None and not poll_client.connect(): was_disconnected = poll_client.sock is None
stop_event.wait(0.10) if was_disconnected:
continue 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]) resp = poll_client.send_command(["get_property", REQUEST_PROP])
if not resp: if not resp:
if note_ipc_unavailable is not None:
note_ipc_unavailable("request-poll-read")
try: try:
poll_client.disconnect() poll_client.disconnect()
except Exception: except Exception:
@@ -1232,10 +1257,14 @@ def _start_request_poll_loop(
stop_event.wait(0.10) stop_event.wait(0.10)
continue continue
if mark_alive is not None:
mark_alive("request-poll-read")
if resp.get("error") == "success": if resp.get("error") == "success":
handle_request(resp.get("data"), "poll") handle_request(resp.get("data"), "poll")
stop_event.wait(0.05) stop_event.wait(0.05)
except Exception: except Exception:
if note_ipc_unavailable is not None:
note_ipc_unavailable("request-poll-exception")
try: try:
poll_client.disconnect() poll_client.disconnect()
except Exception: except Exception:
@@ -1386,9 +1415,48 @@ def main(argv: Optional[list[str]] = None) -> int:
seen_request_ttl_seconds = 180.0 seen_request_ttl_seconds = 180.0
request_processing_lock = threading.Lock() request_processing_lock = threading.Lock()
command_client_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 _send_helper_command = lambda _command, _label='': False
_publish_store_choices_cached_property = lambda _choices: None _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]: def _write_error_log(text: str, *, req_id: str) -> Optional[str]:
try: try:
error_log_dir.mkdir(parents=True, exist_ok=True) error_log_dir.mkdir(parents=True, exist_ok=True)
@@ -1593,6 +1661,7 @@ def main(argv: Optional[list[str]] = None) -> int:
while True: while True:
try: try:
if client.connect(): if client.connect():
_mark_ipc_alive("startup-connect")
break break
except Exception as exc: except Exception as exc:
last_connect_error = f"{type(exc).__name__}: {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: def _send_helper_command(command: Any, label: str = "") -> bool:
with command_client_lock: with command_client_lock:
try: try:
if command_client.sock is None and not command_client.connect(): if command_client.sock is None:
_append_helper_log( if not command_client.connect():
f"[helper-ipc] connect failed label={label or '?'}" _append_helper_log(
) f"[helper-ipc] connect failed label={label or '?'}"
return False )
_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) rid = command_client.send_command_no_wait(command)
if rid is None: if rid is None:
_append_helper_log( _append_helper_log(
f"[helper-ipc] send failed label={label or '?'}" f"[helper-ipc] send failed label={label or '?'}"
) )
_note_ipc_unavailable(f"helper-command-send:{label or '?'}")
try: try:
command_client.disconnect() command_client.disconnect()
except Exception: except Exception:
pass pass
return False return False
_mark_ipc_alive(f"helper-command-send:{label or '?'}")
return True return True
except Exception as exc: except Exception as exc:
_append_helper_log( _append_helper_log(
f"[helper-ipc] exception label={label or '?'} error={type(exc).__name__}: {exc}" f"[helper-ipc] exception label={label or '?'} error={type(exc).__name__}: {exc}"
) )
_note_ipc_unavailable(f"helper-command-exception:{label or '?'}")
try: try:
command_client.disconnect() command_client.disconnect()
except Exception: except Exception:
@@ -1740,9 +1815,19 @@ def main(argv: Optional[list[str]] = None) -> int:
except Exception: except Exception:
pass pass
heartbeat_stop = threading.Event() _start_ready_heartbeat(
_start_ready_heartbeat(str(args.ipc), heartbeat_stop) str(args.ipc),
_start_request_poll_loop(str(args.ipc), heartbeat_stop, _process_request) 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 # 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). # 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: except Exception:
pass pass
while True: try:
msg = client.read_message(timeout=0.25) while not stop_event.is_set():
if msg is None: msg = client.read_message(timeout=0.25)
# Keep READY fresh even when idle (Lua may clear it on timeouts). if msg is None:
_touch_ready() if client.sock is None:
time.sleep(0.02) _note_ipc_unavailable("main-read")
continue 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__": _mark_ipc_alive("main-read")
try:
_flush_mpv_repeat()
except Exception:
pass
heartbeat_stop.set()
return 0
if msg.get("event") == "log-message": if msg.get("event") == "__eof__":
try: _request_shutdown("mpv closed ipc stream")
level = str(msg.get("level") or "") break
prefix = str(msg.get("prefix") or "")
text = str(msg.get("text") or "").rstrip()
if not text: if msg.get("event") == "log-message":
continue 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 text:
if not debug_enabled:
lower_prefix = prefix.lower()
if "quic" in lower_prefix and "DEBUG:" in text:
continue continue
# Suppress progress-bar style lines (keep true errors).
if ("ETA" in text or "%" in text) and ("ERROR:" not in text # Filter excessive noise unless debug is enabled.
and "WARNING:" not in text): if not debug_enabled:
# Typical yt-dlp progress bar line. lower_prefix = prefix.lower()
if text.lstrip().startswith("["): if "quic" in lower_prefix and "DEBUG:" in text:
continue 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() now = time.time()
if last_mpv_line == line and (now - last_mpv_ts) < 2.0: if last_mpv_line == line and (now - last_mpv_ts) < 2.0:
last_mpv_count += 1 last_mpv_count += 1
last_mpv_ts = now
continue
_flush_mpv_repeat()
last_mpv_line = line
last_mpv_count = 1
last_mpv_ts = now last_mpv_ts = now
continue _append_helper_log(line)
except Exception:
pass
continue
_flush_mpv_repeat() if msg.get("event") != "property-change":
last_mpv_line = line continue
last_mpv_count = 1
last_mpv_ts = now if msg.get("id") != OBS_ID_REQUEST:
_append_helper_log(line) 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: except Exception:
pass pass
continue try:
command_client.disconnect()
except Exception:
pass
try:
client.disconnect()
except Exception:
pass
if msg.get("event") != "property-change": return 0
continue
if msg.get("id") != OBS_ID_REQUEST:
continue
_process_request(msg.get("data"), "observe")
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -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). without triggering backend initialization (which may perform network calls).
Behaviour: Behaviour:
- For each configured store type, returns the per-instance NAME override (case-insensitive) - Only includes store types that map to a discovered save backend class.
when present, otherwise the instance key. - 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: try:
store_cfg = (config or {}).get("store") or {} store_cfg = (config or {}).get("store") or {}
if not isinstance(store_cfg, dict): if not isinstance(store_cfg, dict):
return [] return []
classes_by_type = _discover_store_classes()
names: list[str] = [] names: list[str] = []
for raw_store_type, instances in store_cfg.items(): for raw_store_type, instances in store_cfg.items():
if not isinstance(instances, dict): if not isinstance(instances, dict):
continue 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(): 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): if isinstance(instance_config, dict):
override_name = _get_case_insensitive(dict(instance_config), "NAME") override_name = _get_case_insensitive(dict(instance_config), "NAME")
if override_name: if override_name:

View File

@@ -180,6 +180,7 @@ class Add_File(Cmdlet):
arg=[ arg=[
SharedArgs.PATH, SharedArgs.PATH,
SharedArgs.STORE, SharedArgs.STORE,
SharedArgs.URL,
SharedArgs.PROVIDER, SharedArgs.PROVIDER,
CmdletArg( CmdletArg(
name="delete", name="delete",
@@ -220,6 +221,7 @@ class Add_File(Cmdlet):
path_arg = parsed.get("path") path_arg = parsed.get("path")
location = parsed.get("store") location = parsed.get("store")
source_url_arg = parsed.get("url")
provider_name = parsed.get("provider") provider_name = parsed.get("provider")
delete_after = parsed.get("delete", False) delete_after = parsed.get("delete", False)
@@ -519,6 +521,29 @@ class Add_File(Cmdlet):
for idx, item in enumerate(items_to_process, 1): for idx, item in enumerate(items_to_process, 1):
pipe_obj = coerce_to_pipe_object(item, path_arg) 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: try:
label = pipe_obj.title label = pipe_obj.title
if not label and pipe_obj.path: if not label and pipe_obj.path: