From be5a11da97166db1d107705a522d33dd300ef925 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 30 Apr 2026 18:56:22 -0700 Subject: [PATCH] huge refactor of plugin system --- API/HydrusNetwork.py | 2730 ----------------- CLI.py | 6 +- MPV/__init__.py | 6 +- MPV/format_probe.py | 48 +- MPV/lyric.py | 2023 +----------- MPV/mpv_ipc.py | 1173 +------ MPV/pipeline_helper.py | 2160 +------------ Provider/__init__.py | 6 - Provider/example_provider.py | 8 - Provider/metadata_provider.py | 8 - Provider/tidal_manifest.py | 8 - ProviderCore/base.py | 53 +- ProviderCore/commands.py | 99 + ProviderCore/registry.py | 24 + SYS/cmdlet_catalog.py | 19 +- SYS/cmdlet_spec.py | 10 +- SYS/command_parsing.py | 79 + SYS/pipeline.py | 8 - SYS/plugin_config.py | 11 +- SYS/result_table.py | 73 +- Store/HydrusNetwork.py | 2702 ---------------- Store/registry.py | 102 +- cmdlet/__init__.py | 12 + cmdlet/_shared.py | 426 ++- cmdlet/add_file.py | 130 +- cmdlet/add_tag.py | 28 + cmdlet/delete_tag.py | 37 +- cmdlet/search_file.py | 8 +- cmdnat/__init__.py | 11 +- cmdnat/out_table.py | 165 - cmdnat/status.py | 2 +- cmdnat/table.py | 356 ++- docs/MENU_TROUBLESHOOTING.md | 12 +- docs/tag_template_syntax.md | 371 +++ plugins/README.md | 2 +- plugins/alldebrid/__init__.py | 6 +- .../alldebrid/api/__init__.py | 2 +- plugins/hifi/__init__.py | 2 +- plugins/loc/__init__.py | 2 +- API/loc.py => plugins/loc/api/__init__.py | 2 +- .../matrix.py => plugins/matrix/commands.py | 8 +- plugins/metadata_provider.py | 2 +- {MPV => plugins/mpv}/LUA/main.lua | 95 +- {MPV => plugins/mpv}/LUA/sleep_timer.lua | 0 {MPV => plugins/mpv}/LUA/trim.lua | 0 plugins/mpv/__init__.py | 5 + cmdnat/pipe.py => plugins/mpv/commands.py | 341 +- plugins/mpv/format_probe.py | 55 + plugins/mpv/lyric.py | 2036 ++++++++++++ plugins/mpv/mpv_ipc.py | 1243 ++++++++ plugins/mpv/pipeline_helper.py | 2173 +++++++++++++ .../mpv}/portable_config/input.conf | 0 {MPV => plugins/mpv}/portable_config/mpv.conf | 2 +- .../script-opts/medeia-selected-store.json | 0 .../script-opts/medeia-store-cache.json | 0 .../portable_config/script-opts/medeia.conf | 0 .../portable_config/script-opts/uosc.conf | 0 .../script-opts/ytdl_hook.conf | 0 .../mpv}/portable_config/scripts/uosc.lua | 0 .../scripts/uosc/fonts/uosc_icons.otf | Bin .../scripts/uosc/fonts/uosc_textures.ttf | Bin .../uosc/scripts/uosc/bin/ziggy-darwin | Bin .../scripts/uosc/scripts/uosc/bin/ziggy-linux | Bin .../uosc/scripts/uosc/char-conv/zh.json | 0 .../uosc/elements/BufferingIndicator.lua | 0 .../uosc/scripts/uosc/elements/Button.lua | 0 .../uosc/scripts/uosc/elements/Controls.lua | 0 .../uosc/scripts/uosc/elements/Curtain.lua | 0 .../scripts/uosc/elements/CycleButton.lua | 0 .../uosc/scripts/uosc/elements/Element.lua | 0 .../uosc/scripts/uosc/elements/Elements.lua | 0 .../scripts/uosc/elements/ManagedButton.lua | 0 .../uosc/scripts/uosc/elements/Menu.lua | 0 .../scripts/uosc/elements/PauseIndicator.lua | 0 .../uosc/scripts/uosc/elements/Speed.lua | 0 .../uosc/scripts/uosc/elements/Timeline.lua | 0 .../uosc/scripts/uosc/elements/TopBar.lua | 0 .../uosc/scripts/uosc/elements/Updater.lua | 0 .../uosc/scripts/uosc/elements/Volume.lua | 0 .../scripts/uosc/elements/WindowBorder.lua | 0 .../scripts/uosc/scripts/uosc/intl/de.json | 0 .../scripts/uosc/scripts/uosc/intl/es.json | 0 .../scripts/uosc/scripts/uosc/intl/fr.json | 0 .../scripts/uosc/scripts/uosc/intl/pl.json | 0 .../scripts/uosc/scripts/uosc/intl/ro.json | 0 .../scripts/uosc/scripts/uosc/intl/ru.json | 0 .../scripts/uosc/scripts/uosc/intl/tr.json | 0 .../scripts/uosc/scripts/uosc/intl/uk.json | 0 .../uosc/scripts/uosc/intl/zh-hans.json | 0 .../scripts/uosc/scripts/uosc/main.lua | 0 .../portable_config/scripts/uosc/uosc.conf | 0 {MPV => plugins/mpv}/splash.png | Bin plugins/podcastindex/__init__.py | 4 +- .../podcastindex/api/__init__.py | 2 +- .../telegram/commands.py | 4 +- plugins/tidal/__init__.py | 2 +- API/Tidal.py => plugins/tidal/api/__init__.py | 10 +- readme.md | 2 + scripts/bootstrap.py | 9 + 99 files changed, 7603 insertions(+), 11320 deletions(-) delete mode 100644 API/HydrusNetwork.py delete mode 100644 Provider/__init__.py delete mode 100644 Provider/example_provider.py delete mode 100644 Provider/metadata_provider.py delete mode 100644 Provider/tidal_manifest.py create mode 100644 ProviderCore/commands.py create mode 100644 SYS/command_parsing.py delete mode 100644 Store/HydrusNetwork.py delete mode 100644 cmdnat/out_table.py create mode 100644 docs/tag_template_syntax.md rename API/alldebrid.py => plugins/alldebrid/api/__init__.py (99%) rename API/loc.py => plugins/loc/api/__init__.py (98%) rename cmdnat/matrix.py => plugins/matrix/commands.py (99%) rename {MPV => plugins/mpv}/LUA/main.lua (98%) rename {MPV => plugins/mpv}/LUA/sleep_timer.lua (100%) rename {MPV => plugins/mpv}/LUA/trim.lua (100%) create mode 100644 plugins/mpv/__init__.py rename cmdnat/pipe.py => plugins/mpv/commands.py (94%) create mode 100644 plugins/mpv/format_probe.py create mode 100644 plugins/mpv/lyric.py create mode 100644 plugins/mpv/mpv_ipc.py create mode 100644 plugins/mpv/pipeline_helper.py rename {MPV => plugins/mpv}/portable_config/input.conf (100%) rename {MPV => plugins/mpv}/portable_config/mpv.conf (98%) rename {MPV => plugins/mpv}/portable_config/script-opts/medeia-selected-store.json (100%) rename {MPV => plugins/mpv}/portable_config/script-opts/medeia-store-cache.json (100%) rename {MPV => plugins/mpv}/portable_config/script-opts/medeia.conf (100%) rename {MPV => plugins/mpv}/portable_config/script-opts/uosc.conf (100%) rename {MPV => plugins/mpv}/portable_config/script-opts/ytdl_hook.conf (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/fonts/uosc_icons.otf (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/fonts/uosc_textures.ttf (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-darwin (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-linux (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/char-conv/zh.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/BufferingIndicator.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Button.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Curtain.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/CycleButton.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Element.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Elements.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/ManagedButton.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Menu.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/PauseIndicator.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Speed.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Timeline.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/TopBar.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Updater.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/Volume.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/elements/WindowBorder.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/de.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/es.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/fr.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/pl.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/ro.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/ru.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/tr.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/uk.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/intl/zh-hans.json (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/scripts/uosc/main.lua (100%) rename {MPV => plugins/mpv}/portable_config/scripts/uosc/uosc.conf (100%) rename {MPV => plugins/mpv}/splash.png (100%) rename API/podcastindex.py => plugins/podcastindex/api/__init__.py (99%) rename cmdnat/telegram.py => plugins/telegram/commands.py (98%) rename API/Tidal.py => plugins/tidal/api/__init__.py (97%) diff --git a/API/HydrusNetwork.py b/API/HydrusNetwork.py deleted file mode 100644 index b948631..0000000 --- a/API/HydrusNetwork.py +++ /dev/null @@ -1,2730 +0,0 @@ -"""Compatibility shim for the Hydrus plugin-owned API module.""" - -from plugins.hydrusnetwork.api import * # noqa: F401,F403 - - -class HydrusRequestError(RuntimeError): - """Raised when the Hydrus Client API returns an error response.""" - - def __init__(self, status: int, message: str, payload: Any | None = None) -> None: - super().__init__(f"Hydrus request failed ({status}): {message}") - self.status = status - self.payload = payload - - -class HydrusConnectionError(HydrusRequestError): - """Raised when Hydrus service is unavailable (connection refused, timeout, etc.). - - This is an expected error when Hydrus is not running and should not include - a full traceback in logs. - """ - - def __init__(self, message: str) -> None: - super().__init__(0, message, None) # status 0 indicates connection error - self.is_connection_error = True - - -@dataclass(slots=True) -class HydrusRequestSpec: - method: str - endpoint: str - query: dict[str, Any] | None = None - data: Any | None = None - file_path: Path | None = None - content_type: str | None = None - accept: str | None = "application/cbor" - - -@dataclass(slots=True) -class HydrusNetwork: - """Thin wrapper around the Hydrus Client API.""" - - url: str - access_key: str = "" - timeout: float = 9.0 - upload_io_timeout: float = 120.0 - upload_chunk_size: int = 64 * 1024 - instance_name: str = "" # Optional store name (e.g., 'home') for namespaced logs - - scheme: str = field(init=False) - hostname: str = field(init=False) - port: int = field(init=False) - base_path: str = field(init=False) - _session_key: str = field(init=False, default="", repr=False) # Cached session key - - def __post_init__(self) -> None: - if not self.url: - raise ValueError("Hydrus base URL is required") - self.url = self.url.rstrip("/") - parsed = urlsplit(self.url) - if parsed.scheme not in {"http", - "https"}: - raise ValueError("Hydrus base URL must use http or https") - self.scheme = parsed.scheme - self.hostname = parsed.hostname or "localhost" - self.port = parsed.port or (443 if self.scheme == "https" else 80) - self.base_path = parsed.path.rstrip("/") - self.access_key = self.access_key or "" - self.instance_name = str(self.instance_name or "").strip() - - def _log_prefix(self) -> str: - if self.instance_name: - return f"[hydrusnetwork:{self.instance_name}]" - return f"[hydrusnetwork:{self.hostname}:{self.port}]" - - # ------------------------------------------------------------------ - # low-level helpers - # ------------------------------------------------------------------ - - def _build_path(self, endpoint: str, query: dict[str, Any] | None = None) -> str: - path = endpoint if endpoint.startswith("/") else f"/{endpoint}" - if self.base_path: - path = f"{self.base_path}{path}" - if query: - encoded = urlencode(query, doseq=True) - if encoded: - path = f"{path}?{encoded}" - return path - - def _perform_request(self, spec: HydrusRequestSpec) -> Any: - headers: dict[str, - str] = {} - - # Use session key if available, otherwise use access key - if self._session_key: - headers["Hydrus-Client-API-Session-Key"] = self._session_key - elif self.access_key: - headers["Hydrus-Client-API-Access-Key"] = self.access_key - if spec.accept: - headers["Accept"] = spec.accept - - path = self._build_path(spec.endpoint, spec.query) - url = f"{self.scheme}://{self.hostname}:{self.port}{path}" - - # Log request details - logger.debug( - f"{self._log_prefix()} {spec.method} {spec.endpoint} (auth: {'session_key' if self._session_key else 'access_key' if self.access_key else 'none'})" - ) - - status = 0 - reason = "" - body = b"" - content_type = "" - - try: - with HTTPClient(timeout=self.timeout, - headers=headers, - verify_ssl=False, - retries=1) as client: - response = None - - if spec.file_path is not None: - file_path = Path(spec.file_path) - if not file_path.is_file(): - error_msg = f"Upload file not found: {file_path}" - logger.error(f"{self._log_prefix()} {error_msg}") - raise FileNotFoundError(error_msg) - - file_size = file_path.stat().st_size - headers["Content-Type" - ] = spec.content_type or "application/octet-stream" - # Do not set Content-Length when streaming an iterator body. - # If the file size changes between stat() and read() (or the source is truncated), - # h11 will raise: "Too little data for declared Content-Length". - # Let httpx choose chunked transfer encoding for safety. - headers.pop("Content-Length", None) - - logger.debug( - f"{self._log_prefix()} Uploading file {file_path.name} ({file_size} bytes)" - ) - - # Upload timeout policy: - # - Keep normal requests fast via `self.timeout`. - # - For streaming uploads, use an activity timeout for read/write so - # long transfers do not fail due to a short generic timeout. - # - If upload_io_timeout <= 0, disable read/write timeout entirely. - try: - upload_io_timeout = float(self.upload_io_timeout) - except Exception: - upload_io_timeout = 120.0 - if upload_io_timeout <= 0: - upload_timeout = httpx.Timeout( - connect=float(self.timeout), - read=None, - write=None, - pool=float(self.timeout), - ) - else: - upload_timeout = httpx.Timeout( - connect=float(self.timeout), - read=upload_io_timeout, - write=upload_io_timeout, - pool=float(self.timeout), - ) - - try: - chunk_size = int(self.upload_chunk_size) - except Exception: - chunk_size = 64 * 1024 - if chunk_size <= 0: - chunk_size = 64 * 1024 - - # Stream upload body with a stderr progress bar (pipeline-safe). - from SYS.models import ProgressBar - - bar = ProgressBar() - # Keep the PipelineLiveProgress transfer line clean: show the file name. - # (The hydrus instance/service is already visible in the logs above.) - label = str(getattr(file_path, "name", None) or "upload") - traffic_sent_total = [0] - - upload_attempts = 3 - last_upload_exc: Exception | None = None - for attempt in range(upload_attempts): - sent_this_attempt = [0] - last_render_t = [time.time()] - - def _render_progress(final: bool = False) -> None: - if file_size <= 0: - return - now = time.time() - if not final and (now - float(last_render_t[0])) < 0.25: - return - last_render_t[0] = now - bar.update( - downloaded=int(sent_this_attempt[0]), - total=int(file_size), - label=str(label), - file=sys.stderr, - ) - if final: - bar.finish() - - def file_gen(): - try: - with file_path.open("rb") as handle: - while True: - chunk = handle.read(chunk_size) - if not chunk: - break - sent_this_attempt[0] += len(chunk) - traffic_sent_total[0] += len(chunk) - _render_progress(final=False) - yield chunk - finally: - _render_progress(final=True) - - try: - response = client.request( - spec.method, - url, - content=file_gen(), - headers=headers, - timeout=upload_timeout, - raise_for_status=False, - log_http_errors=False, - ) - logger.debug( - f"{self._log_prefix()} Upload completed on attempt {attempt + 1}/{upload_attempts}; " - f"attempt_bytes={int(sent_this_attempt[0])}, total_traffic_bytes={int(traffic_sent_total[0])}" - ) - last_upload_exc = None - break - except (httpx.TimeoutException, httpx.RequestError) as exc: - last_upload_exc = exc - logger.warning( - f"{self._log_prefix()} Upload timeout/error on attempt {attempt + 1}/{upload_attempts}: {exc} " - f"(attempt_bytes={int(sent_this_attempt[0])}, total_traffic_bytes={int(traffic_sent_total[0])})" - ) - if attempt < upload_attempts - 1: - try: - time.sleep(0.75 * (attempt + 1)) - except Exception: - pass - continue - raise - if response is None and last_upload_exc is not None: - raise last_upload_exc - else: - content = None - json_data = None - if spec.data is not None: - if isinstance(spec.data, (bytes, bytearray)): - content = spec.data - else: - json_data = spec.data - # Hydrus expects JSON bodies to be sent with Content-Type: application/json. - # httpx will usually set this automatically, but we set it explicitly to - # match the Hydrus API docs and avoid edge cases. - headers.setdefault("Content-Type", "application/json") - logger.debug( - f"{self._log_prefix()} Request body size: {len(content) if content else 'json'}" - ) - - response = client.request( - spec.method, - url, - content=content, - json=json_data, - headers=headers, - raise_for_status=False, - log_http_errors=False, - ) - - status = response.status_code - reason = response.reason_phrase - body = response.content - content_type = response.headers.get("Content-Type", "") or "" - - logger.debug( - f"{self._log_prefix()} Response {status} {reason} ({len(body)} bytes)" - ) - - except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc: - msg = f"Hydrus unavailable: {exc}" - logger.warning(f"{self._log_prefix()} {msg}") - raise HydrusConnectionError(msg) from exc - except Exception as exc: - logger.error(f"{self._log_prefix()} Connection error: {exc}", exc_info=True) - raise - - payload: Any - payload = {} - if body: - content_main = content_type.split(";", 1)[0].strip().lower() - if "json" in content_main: - try: - payload = json.loads(body.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - payload = body.decode("utf-8", "replace") - elif "cbor" in content_main: - try: - payload = decode_cbor(body) - except Exception: - payload = body - else: - payload = body - - if status >= 400: - message = "" - if isinstance(payload, dict): - message = str(payload.get("message") or payload.get("error") or payload) - elif isinstance(payload, str): - message = payload - else: - message = reason or "HTTP error" - - # Some endpoints are naturally "missing" sometimes and should not spam logs. - if status == 404 and spec.endpoint.rstrip("/") == "/get_files/file_path": - # Some Hydrus deployments do not expose local file system paths via - # /get_files/file_path. Treat 404 as 'not supported' and let callers - # fall back to HTTP download URLs instead of raising an error. - logger.debug(f"{self._log_prefix()} /get_files/file_path returned 404 (not supported) - caller should fallback to HTTP") - return {} - - logger.error(f"{self._log_prefix()} HTTP {status}: {message}") - - # Handle expired session key (419) by clearing cache and retrying once - if status == 419 and self._session_key and "session" in message.lower(): - logger.warning( - f"{self._log_prefix()} Session key expired, acquiring new one and retrying..." - ) - self._session_key = "" # Clear expired session key - try: - self._acquire_session_key() - # Retry the request with new session key - return self._perform_request(spec) - except Exception as retry_error: - logger.error( - f"{self._log_prefix()} Retry failed: {retry_error}", - exc_info=True - ) - # If retry fails, raise the original error - raise HydrusRequestError(status, message, payload) from retry_error - - raise HydrusRequestError(status, message, payload) - - return payload - - def _acquire_session_key(self) -> str: - """Acquire a session key from the Hydrus API using the access key. - - Session keys are temporary authentication tokens that expire after 24 hours - of inactivity, client restart, or if the access key is deleted. They are - more secure than passing access keys in every request. - - Returns the session key string. - Raises HydrusRequestError if the request fails. - """ - if not self.access_key: - raise HydrusRequestError( - 401, - "Cannot acquire session key: no access key configured" - ) - - # Temporarily use access key to get session key - original_session_key = self._session_key - try: - self._session_key = "" # Clear session key to use access key for this request - - result = self._get("/session_key") - session_key = result.get("session_key") - - if not session_key: - raise HydrusRequestError( - 500, - "Session key response missing 'session_key' field", - result - ) - - self._session_key = session_key - return session_key - except HydrusRequestError: - self._session_key = original_session_key - raise - except Exception as e: - self._session_key = original_session_key - raise HydrusRequestError(500, f"Failed to acquire session key: {e}") - - def ensure_session_key(self) -> str: - """Ensure a valid session key exists, acquiring one if needed. - - Returns the session key. If one is already cached, returns it. - Otherwise acquires a new session key from the API. - """ - if self._session_key: - return self._session_key - return self._acquire_session_key() - - def _get(self, - endpoint: str, - *, - query: dict[str, - Any] | None = None) -> dict[str, - Any]: - spec = HydrusRequestSpec("GET", endpoint, query=query) - return cast(dict[str, Any], self._perform_request(spec)) - - def _post( - self, - endpoint: str, - *, - data: dict[str, - Any] | None = None, - file_path: Path | None = None, - content_type: str | None = None, - ) -> dict[str, - Any]: - spec = HydrusRequestSpec( - "POST", - endpoint, - data=data, - file_path=file_path, - content_type=content_type - ) - return cast(dict[str, Any], self._perform_request(spec)) - - def _ensure_hashes(self, hash: Union[str, Iterable[str]]) -> list[str]: - if isinstance(hash, str): - return [hash] - return list(hash) - - def _append_access_key(self, url: str) -> str: - if not self.access_key: - return url - separator = "&" if "?" in url else "?" - # Use the correct parameter name for Hydrus API compatibility - return f"{url}{separator}access_key={quote(self.access_key)}" - - def add_file(self, path: Union[str, Path]) -> dict[str, Any]: - """Add a file to Hydrus using the octet-stream upload mode. - - This mirrors the Hydrus API POST /add_files/add_file behavior when sending - the file bytes as the POST body. The method accepts either a filesystem - `Path` or a string path and will raise FileNotFoundError if the target - path is not a readable file. - """ - # Accept both Path and str for convenience - file_path = Path(path) if not isinstance(path, Path) else path - if not file_path.is_file(): - raise FileNotFoundError(f"Upload file not found: {file_path}") - - # Forward as file_path so the request body is streamed as application/octet-stream - return self._post("/add_files/add_file", file_path=file_path) - - def undelete_files(self, hashes: Union[str, Iterable[str]]) -> dict[str, Any]: - """Restore files from Hydrus trash back into 'my files'. - - Hydrus Client API: POST /add_files/undelete_files - Required JSON args: {"hashes": [, ...]} - """ - hash_list = self._ensure_hashes(hashes) - body = { - "hashes": hash_list - } - return self._post("/add_files/undelete_files", data=body) - - def delete_files( - self, - hashes: Union[str, - Iterable[str]], - *, - reason: str | None = None - ) -> dict[str, - Any]: - """Delete files in Hydrus. - - Hydrus Client API: POST /add_files/delete_files - Required JSON args: {"hashes": [, ...]} - Optional JSON args: {"reason": "..."} - """ - hash_list = self._ensure_hashes(hashes) - body: dict[str, - Any] = { - "hashes": hash_list - } - if isinstance(reason, str) and reason.strip(): - body["reason"] = reason.strip() - return self._post("/add_files/delete_files", data=body) - - def clear_file_deletion_record(self, - hashes: Union[str, - Iterable[str]]) -> dict[str, - Any]: - """Clear Hydrus's file deletion record for the provided hashes. - - Hydrus Client API: POST /add_files/clear_file_deletion_record - Required JSON args: {"hashes": [, ...]} - """ - hash_list = self._ensure_hashes(hashes) - body = { - "hashes": hash_list - } - return self._post("/add_files/clear_file_deletion_record", data=body) - - def add_tag( - self, - hash: Union[str, - Iterable[str]], - tags: Iterable[str], - service_name: str - ) -> dict[str, - Any]: - hash = self._ensure_hashes(hash) - body = { - "hashes": hash, - "service_names_to_tags": { - service_name: list(tags) - } - } - return self._post("/add_tags/add_tags", data=body) - - def delete_tag( - self, - file_hashes: Union[str, - Iterable[str]], - tags: Iterable[str], - service_name: str, - *, - action: int = 1, - ) -> dict[str, - Any]: - hashes = self._ensure_hashes(file_hashes) - body = { - "hashes": hashes, - "service_names_to_actions_to_tags": { - service_name: { - action: list(tags) - } - }, - } - return self._post("/add_tags/add_tags", data=body) - - def add_tags_by_key( - self, - hash: Union[str, - Iterable[str]], - tags: Iterable[str], - service_key: str - ) -> dict[str, - Any]: - hash = self._ensure_hashes(hash) - body = { - "hashes": hash, - "service_keys_to_tags": { - service_key: list(tags) - } - } - return self._post("/add_tags/add_tags", data=body) - - def delete_tags_by_key( - self, - file_hashes: Union[str, - Iterable[str]], - tags: Iterable[str], - service_key: str, - *, - action: int = 1, - ) -> dict[str, - Any]: - hashes = self._ensure_hashes(file_hashes) - body = { - "hashes": hashes, - "service_keys_to_actions_to_tags": { - service_key: { - action: list(tags) - } - }, - } - return self._post("/add_tags/add_tags", data=body) - - def mutate_tags_by_key( - self, - hash: Union[str, - Iterable[str]], - service_key: str, - *, - add_tags: Optional[Iterable[str]] = None, - remove_tags: Optional[Iterable[str]] = None, - ) -> dict[str, - Any]: - """Add or remove tags with a single /add_tags/add_tags call. - - Hydrus Client API: POST /add_tags/add_tags - Use `service_keys_to_actions_to_tags` so the client can apply additions - and removals in a single request (action '0' = add, '1' = remove). - """ - hash_list = self._ensure_hashes(hash) - def _clean(tags: Optional[Iterable[str]]) -> list[str]: - if not tags: - return [] - clean_list: list[str] = [] - for tag in tags: - if not isinstance(tag, str): - continue - text = tag.strip() - if not text: - continue - clean_list.append(text) - return clean_list - - actions: dict[str, list[str]] = {} - adds = _clean(add_tags) - removes = _clean(remove_tags) - if adds: - actions["0"] = adds - if removes: - actions["1"] = removes - if not actions: - return {} - body = { - "hashes": hash_list, - "service_keys_to_actions_to_tags": { - str(service_key): actions - }, - } - return self._post("/add_tags/add_tags", data=body) - - def associate_url(self, - file_hashes: Union[str, - Iterable[str]], - url: str) -> dict[str, - Any]: - hashes = self._ensure_hashes(file_hashes) - if len(hashes) == 1: - body = { - "hash": hashes[0], - "url_to_add": url - } - return self._post("/add_urls/associate_url", data=body) - - results: dict[str, - Any] = {} - for file_hash in hashes: - body = { - "hash": file_hash, - "url_to_add": url - } - results[file_hash] = self._post("/add_urls/associate_url", data=body) - return { - "batched": results - } - - def get_url_info(self, url: str) -> dict[str, Any]: - """Get information about a URL. - - Hydrus Client API: GET /add_urls/get_url_info - Docs: https://hydrusnetwork.github.io/hydrus/developer_api.html#add_urls_get_url_info - """ - url = str(url or "").strip() - if not url: - raise ValueError("url must not be empty") - - spec = HydrusRequestSpec( - method="GET", - endpoint="/add_urls/get_url_info", - query={ - "url": url - }, - ) - return cast(dict[str, Any], self._perform_request(spec)) - - def delete_url(self, - file_hashes: Union[str, - Iterable[str]], - url: str) -> dict[str, - Any]: - hashes = self._ensure_hashes(file_hashes) - if len(hashes) == 1: - body = { - "hash": hashes[0], - "url_to_delete": url - } - return self._post("/add_urls/associate_url", data=body) - - results: dict[str, - Any] = {} - for file_hash in hashes: - body = { - "hash": file_hash, - "url_to_delete": url - } - results[file_hash] = self._post("/add_urls/associate_url", data=body) - return { - "batched": results - } - - def set_notes( - self, - file_hash: str, - notes: dict[str, - str], - *, - merge_cleverly: bool = False, - extend_existing_note_if_possible: bool = True, - conflict_resolution: int = 3, - ) -> dict[str, - Any]: - """Add or update notes associated with a file. - - Hydrus Client API: POST /add_notes/set_notes - Required JSON args: {"hash": , "notes": {name: text}} - """ - if not notes: - raise ValueError("notes mapping must not be empty") - - file_hash = str(file_hash or "").strip().lower() - if not file_hash: - raise ValueError("file_hash must not be empty") - - body: dict[str, - Any] = { - "hash": file_hash, - "notes": notes - } - - if merge_cleverly: - body["merge_cleverly"] = True - body["extend_existing_note_if_possible"] = bool( - extend_existing_note_if_possible - ) - body["conflict_resolution"] = int(conflict_resolution) - return self._post("/add_notes/set_notes", data=body) - - def delete_notes( - self, - file_hash: str, - note_names: Sequence[str], - ) -> dict[str, - Any]: - """Delete notes associated with a file. - - Hydrus Client API: POST /add_notes/delete_notes - Required JSON args: {"hash": , "note_names": [..]} - """ - names = [str(name) for name in note_names if str(name or "").strip()] - if not names: - raise ValueError("note_names must not be empty") - - file_hash = str(file_hash or "").strip().lower() - if not file_hash: - raise ValueError("file_hash must not be empty") - - body = { - "hash": file_hash, - "note_names": names - } - return self._post("/add_notes/delete_notes", data=body) - - def get_file_relationships(self, file_hash: str) -> dict[str, Any]: - query = { - "hash": file_hash - } - return self._get( - "/manage_file_relationships/get_file_relationships", - query=query - ) - - def set_relationship( - self, - hash_a: str, - hash_b: str, - relationship: Union[str, - int], - do_default_content_merge: bool = False, - ) -> dict[str, - Any]: - """Set a relationship between two files in Hydrus. - - This wraps Hydrus Client API: POST /manage_file_relationships/set_file_relationships. - - Hydrus relationship enum (per Hydrus developer API docs): - - 0: set as potential duplicates - - 1: set as false positives - - 2: set as same quality (duplicates) - - 3: set as alternates - - 4: set A as better (duplicates) - - Args: - hash_a: First file SHA256 hex - hash_b: Second file SHA256 hex - relationship: Relationship type as string or integer enum (0-4) - do_default_content_merge: Whether to perform default duplicate content merge - - Returns: - Response from Hydrus API - """ - # Convert string relationship types to integers - if isinstance(relationship, str): - rel_map = { - # Potential duplicates - "potential": 0, - "potentials": 0, - "potential duplicate": 0, - "potential duplicates": 0, - # False positives - "false positive": 1, - "false_positive": 1, - "false positives": 1, - "false_positives": 1, - "not related": 1, - "not_related": 1, - # Duplicates (same quality) - "duplicate": 2, - "duplicates": 2, - "same quality": 2, - "same_quality": 2, - "equal": 2, - # Alternates - "alt": 3, - "alternate": 3, - "alternates": 3, - "alternative": 3, - "related": 3, - # Better/worse (duplicates) - "better": 4, - "a better": 4, - "a_better": 4, - # Back-compat: some older call sites used 'king' for primary. - # Hydrus does not accept 'king' as a relationship; this maps to 'A is better'. - "king": 4, - } - relationship = rel_map.get( - relationship.lower().strip(), - 3 - ) # Default to alternates - - body = { - "relationships": [ - { - "hash_a": hash_a, - "hash_b": hash_b, - "relationship": relationship, - "do_default_content_merge": do_default_content_merge, - } - ] - } - return self._post( - "/manage_file_relationships/set_file_relationships", - data=body - ) - - def get_services(self) -> dict[str, Any]: - return self._get("/get_services") - - def search_files( - self, - tags: Sequence[Any], - *, - file_service_name: str | None = None, - return_hashes: bool = False, - return_file_ids: bool = True, - return_file_count: bool = False, - include_current_tags: bool | None = None, - include_pending_tags: bool | None = None, - file_sort_type: int | None = None, - file_sort_asc: bool | None = None, - file_sort_key: str | None = None, - ) -> dict[str, - Any]: - if not tags: - raise ValueError("tags must not be empty") - - query: dict[str, - Any] = {} - query_fields = [ - ("tags", - tags, lambda v: json.dumps(list(v))), - ("file_service_name", - file_service_name, lambda v: v), - ("return_hashes", - return_hashes, lambda v: "true" if v else None), - ("return_file_ids", - return_file_ids, lambda v: "true" if v else None), - ("return_file_count", - return_file_count, lambda v: "true" if v else None), - ( - "include_current_tags", - include_current_tags, - lambda v: "true" if v else "false" if v is not None else None, - ), - ( - "include_pending_tags", - include_pending_tags, - lambda v: "true" if v else "false" if v is not None else None, - ), - ( - "file_sort_type", - file_sort_type, lambda v: str(v) if v is not None else None - ), - ( - "file_sort_asc", - file_sort_asc, - lambda v: "true" if v else "false" if v is not None else None, - ), - ("file_sort_key", - file_sort_key, lambda v: v), - ] - - for key, value, formatter in query_fields: - if value is None or value == []: - continue - formatted = formatter(value) - if formatted is not None: - query[key] = formatted - - return self._get("/get_files/search_files", query=query) - - def fetch_file_metadata( - self, - *, - file_ids: Sequence[int] | None = None, - hashes: Sequence[str] | None = None, - include_service_keys_to_tags: bool = True, - include_tag_services: bool = False, - include_file_services: bool = False, - include_file_url: bool = False, - include_duration: bool = True, - include_size: bool = True, - include_mime: bool = False, - include_is_trashed: bool = False, - include_notes: bool = False, - ) -> dict[str, - Any]: - if not file_ids and not hashes: - raise ValueError("Either file_ids or hashes must be provided") - - query: dict[str, - Any] = {} - query_fields = [ - ("file_ids", - file_ids, lambda v: json.dumps(list(v))), - ("hashes", - hashes, lambda v: json.dumps(list(v))), - ( - "include_service_keys_to_tags", - include_service_keys_to_tags, - lambda v: "true" if v else None, - ), - ( - "include_tag_services", - include_tag_services, - lambda v: "true" if v else None, - ), - ( - "include_file_services", - include_file_services, - lambda v: "true" if v else None, - ), - ("include_file_url", - include_file_url, lambda v: "true" if v else None), - ("include_duration", - include_duration, lambda v: "true" if v else None), - ("include_size", - include_size, lambda v: "true" if v else None), - ("include_mime", - include_mime, lambda v: "true" if v else None), - ( - "include_is_trashed", - include_is_trashed, - lambda v: "true" if v else None, - ), - ("include_notes", - include_notes, lambda v: "true" if v else None), - ] - - for key, value, formatter in query_fields: - if not value: - continue - formatted = formatter(value) - if formatted is not None: - query[key] = formatted - - return self._get("/get_files/file_metadata", query=query) - - def get_file_path(self, file_hash: str) -> dict[str, Any]: - """Get the local file system path for a given file hash.""" - query = { - "hash": file_hash - } - return self._get("/get_files/file_path", query=query) - - def file_url(self, file_hash: str) -> str: - hash_param = quote(file_hash) - # Don't append access_key parameter for file downloads - use header instead - url = f"{self.url}/get_files/file?hash={hash_param}" - return url - - def thumbnail_url(self, file_hash: str) -> str: - hash_param = quote(file_hash) - # Don't append access_key parameter for file downloads - use header instead - url = f"{self.url}/get_files/thumbnail?hash={hash_param}" - return url - - -HydrusCliOptionsT = TypeVar("HydrusCliOptionsT", bound="HydrusCliOptions") - - -@dataclass(slots=True) -class HydrusCliOptions: - url: str - method: str - access_key: str - accept: str - timeout: float - content_type: str | None - body_bytes: bytes | None = None - body_path: Path | None = None - debug: bool = False - - @classmethod - def from_namespace( - cls: Type[HydrusCliOptionsT], - namespace: Any - ) -> HydrusCliOptionsT: - accept_header = namespace.accept or "application/cbor" - body_bytes: bytes | None = None - body_path: Path | None = None - if namespace.body_file: - body_path = Path(namespace.body_file) - elif namespace.body is not None: - body_bytes = namespace.body.encode("utf-8") - return cls( - url=namespace.url, - method=namespace.method.upper(), - access_key=namespace.access_key or "", - accept=accept_header, - timeout=namespace.timeout, - content_type=namespace.content_type, - body_bytes=body_bytes, - body_path=body_path, - debug=bool(os.environ.get("DOWNLOW_DEBUG")), - ) - - -def hydrus_request(args, parser) -> int: - if args.body and args.body_file: - parser.error("Only one of --body or --body-file may be supplied") - - options = HydrusCliOptions.from_namespace(args) - - parsed = urlsplit(options.url) - if parsed.scheme not in ("http", "https"): - parser.error("Only http and https url are supported") - if not parsed.hostname: - parser.error("Invalid Hydrus URL") - - headers: dict[str, - str] = {} - if options.access_key: - headers["Hydrus-Client-API-Access-Key"] = options.access_key - if options.accept: - headers["Accept"] = options.accept - - request_body_bytes: bytes | None = None - body_path: Path | None = None - if options.body_path is not None: - body_path = options.body_path - if not body_path.is_file(): - parser.error(f"File not found: {body_path}") - headers.setdefault( - "Content-Type", - options.content_type or "application/octet-stream" - ) - headers["Content-Length"] = str(body_path.stat().st_size) - elif options.body_bytes is not None: - request_body_bytes = options.body_bytes - headers["Content-Type"] = options.content_type or "application/json" - assert request_body_bytes is not None - headers["Content-Length"] = str(len(request_body_bytes)) - elif options.content_type: - headers["Content-Type"] = options.content_type - - if parsed.username or parsed.password: - userinfo = f"{parsed.username or ''}:{parsed.password or ''}".encode("utf-8") - headers["Authorization"] = "Basic " + base64.b64encode(userinfo).decode("ascii") - - path = parsed.path or "/" - if parsed.query: - path += "?" + parsed.query - - port = parsed.port - if port is None: - port = 443 if parsed.scheme == "https" else 80 - - connection_cls = ( - http.client.HTTPSConnection - if parsed.scheme == "https" else http.client.HTTPConnection - ) - host = parsed.hostname or "localhost" - connection = connection_cls(host, port, timeout=options.timeout) - - if options.debug: - log( - f"Hydrus connecting to {parsed.scheme}://{host}:{port}{path}", - file=sys.stderr - ) - response_bytes: bytes = b"" - content_type = "" - status = 0 - try: - if body_path is not None: - with body_path.open("rb") as handle: - if options.debug: - size_hint = headers.get("Content-Length", "unknown") - log( - f"Hydrus sending file body ({size_hint} bytes)", - file=sys.stderr - ) - connection.putrequest(options.method, path) - host_header = host - if (parsed.scheme == "http" - and port not in (80, - None)) or (parsed.scheme == "https" - and port not in (443, - None)): - host_header = f"{host}:{port}" - connection.putheader("Host", host_header) - for key, value in headers.items(): - if value: - connection.putheader(key, value) - connection.endheaders() - while True: - chunk = handle.read(65536) - if not chunk: - break - connection.send(chunk) - if options.debug: - log( - "[downlow.py] Hydrus upload complete; awaiting response", - file=sys.stderr - ) - else: - if options.debug: - size_hint = "none" if request_body_bytes is None else str( - len(request_body_bytes) - ) - log(f"Hydrus sending request body bytes={size_hint}", file=sys.stderr) - sanitized_headers = { - k: v - for k, v in headers.items() if v - } - connection.request( - options.method, - path, - body=request_body_bytes, - headers=sanitized_headers - ) - response = connection.getresponse() - status = response.status - response_bytes = response.read() - if options.debug: - log( - f"Hydrus response received ({len(response_bytes)} bytes)", - file=sys.stderr - ) - content_type = response.getheader("Content-Type", "") - except (OSError, http.client.HTTPException) as exc: - log(f"HTTP error: {exc}", file=sys.stderr) - return 1 - finally: - connection.close() - content_type_lower = (content_type or "").split(";", 1)[0].strip().lower() - accept_value = options.accept or "" - expect_cbor = "cbor" in (content_type_lower or "") or "cbor" in accept_value.lower() - payload = None - decode_error: Exception | None = None - if response_bytes: - if expect_cbor: - try: - payload = decode_cbor(response_bytes) - except Exception as exc: # pragma: no cover - library errors surfaced - decode_error = exc - if payload is None and not expect_cbor: - try: - payload = json.loads(response_bytes.decode("utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError): - payload = response_bytes.decode("utf-8", "replace") - elif payload is None and expect_cbor and decode_error is not None: - log( - f"Expected CBOR response but decoding failed: {decode_error}", - file=sys.stderr - ) - return 1 - json_ready = jsonify(payload) if isinstance(payload, (dict, list)) else payload - if options.debug: - log(f"Hydrus {options.method} {options.url} -> {status}", file=sys.stderr) - if isinstance(json_ready, (dict, list)): - log(json.dumps(json_ready, ensure_ascii=False)) - elif json_ready is None: - log("{}") - else: - log(json.dumps({ - "value": json_ready - }, - ensure_ascii=False)) - return 0 if 200 <= status < 400 else 1 - - -def hydrus_export(args, _parser) -> int: - from SYS.metadata import apply_mutagen_metadata, build_ffmpeg_command, prepare_ffmpeg_metadata - - output_path: Path = args.output - original_suffix = output_path.suffix - target_dir = output_path.parent - metadata_payload: Optional[dict[str, Any]] = None - metadata_raw = getattr(args, "metadata_json", None) - if metadata_raw: - try: - parsed = json.loads(metadata_raw) - except json.JSONDecodeError as exc: - log(f"Invalid metadata JSON: {exc}", file=sys.stderr) - return 1 - if isinstance(parsed, dict): - metadata_payload = parsed - else: - log("[downlow.py] Metadata JSON must decode to an object", file=sys.stderr) - return 1 - ffmpeg_metadata = prepare_ffmpeg_metadata(metadata_payload) - - def _normalize_ext(value: Optional[str]) -> Optional[str]: - if not value: - return None - cleaned = value.strip() - if not cleaned: - return None - if not cleaned.startswith("."): # tolerate inputs like "mp4" - cleaned = "." + cleaned.lstrip(".") - return cleaned - - def _extension_from_mime(mime: Optional[str]) -> Optional[str]: - if not mime: - return None - mime_map = { - # Images / bitmaps - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "image/webp": ".webp", - "image/avif": ".avif", - "image/jxl": ".jxl", # JPEG XL - "image/bmp": ".bmp", - "image/heic": ".heic", - "image/heif": ".heif", - "image/x-icon": ".ico", - "image/vnd.microsoft.icon": ".ico", - "image/qoi": ".qoi", # Quite OK Image - "image/tiff": ".tiff", - "image/svg+xml": ".svg", - "image/vnd.adobe.photoshop": ".psd", - # Animation / sequence variants - "image/apng": ".apng", - "image/avif-sequence": ".avifs", - "image/heic-sequence": ".heics", - "image/heif-sequence": ".heifs", - # Video - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/quicktime": ".mov", - "video/ogg": ".ogv", - "video/mpeg": ".mpeg", - "video/x-msvideo": ".avi", - "video/x-flv": ".flv", - "video/x-matroska": ".mkv", - "video/x-ms-wmv": ".wmv", - "video/vnd.rn-realvideo": ".rv", - # Audio - "audio/mpeg": ".mp3", - "audio/mp4": ".m4a", - "audio/ogg": ".ogg", - "audio/flac": ".flac", - "audio/wav": ".wav", - "audio/x-wav": ".wav", - "audio/x-ms-wma": ".wma", - "audio/x-tta": ".tta", - "audio/vnd.wave": ".wav", - "audio/x-wavpack": ".wv", - # Documents / office - "application/pdf": ".pdf", - "application/epub+zip": ".epub", - "application/vnd.djvu": ".djvu", - "application/rtf": ".rtf", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", - "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx", - "application/msword": ".doc", - "application/vnd.ms-excel": ".xls", - "application/vnd.ms-powerpoint": ".ppt", - # Archive / comicbook / zip-like - "application/zip": ".zip", - "application/x-7z-compressed": ".7z", - "application/x-rar-compressed": ".rar", - "application/gzip": ".gz", - "application/x-tar": ".tar", - "application/x-cbz": ".cbz", # often just ZIP with images; CBZ is not an official mime type but used as mapping - # App / project / other - "application/clip": ".clip", # Clip Studio - "application/x-krita": ".kra", - "application/x-procreate": ".procreate", - "application/x-shockwave-flash": ".swf", - } - - return mime_map.get(mime.lower()) - - def _extract_hash(file_url: str) -> Optional[str]: - match = re.search(r"[?&]hash=([0-9a-fA-F]+)", file_url) - return match.group(1) if match else None - - # Ensure output and temp directories exist using global helper - for dir_path in [target_dir, Path(args.tmp_dir) if args.tmp_dir else target_dir]: - try: - ensure_directory(dir_path) - except RuntimeError as exc: - log(f"{exc}", file=sys.stderr) - return 1 - - source_suffix = _normalize_ext(getattr(args, "source_ext", None)) - if source_suffix and source_suffix.lower() == ".bin": - source_suffix = None - - if source_suffix is None: - hydrus_url = getattr(args, "hydrus_url", None) - if not hydrus_url: - try: - from SYS.config import load_config, get_hydrus_url - - hydrus_url = get_hydrus_url(load_config()) - except Exception as exc: - hydrus_url = None - if os.environ.get("DOWNLOW_DEBUG"): - log( - f"hydrus-export could not load Hydrus URL: {exc}", - file=sys.stderr - ) - if hydrus_url: - try: - setattr(args, "hydrus_url", hydrus_url) - except Exception: - pass - resolved_suffix: Optional[str] = None - file_hash = getattr(args, "file_hash", None) or _extract_hash(args.file_url) - if hydrus_url and file_hash: - try: - client = HydrusNetwork( - url=hydrus_url, - access_key=args.access_key, - timeout=args.timeout - ) - meta_response = client.fetch_file_metadata( - hashes=[file_hash], - include_mime=True - ) - entries = meta_response.get("metadata") if isinstance( - meta_response, - dict - ) else None - if isinstance(entries, list) and entries: - entry = entries[0] - ext_value = _normalize_ext( - entry.get("ext") if isinstance(entry, - dict) else None - ) - if ext_value: - resolved_suffix = ext_value - else: - mime_value = entry.get("mime" - ) if isinstance(entry, - dict) else None - resolved_suffix = _extension_from_mime(mime_value) - except Exception as exc: # pragma: no cover - defensive - if os.environ.get("DOWNLOW_DEBUG"): - log(f"hydrus metadata fetch failed: {exc}", file=sys.stderr) - if not resolved_suffix: - fallback_suffix = _normalize_ext(original_suffix) - if fallback_suffix and fallback_suffix.lower() == ".bin": - fallback_suffix = None - resolved_suffix = fallback_suffix or ".hydrus" - source_suffix = resolved_suffix - - suffix = source_suffix or ".hydrus" - if suffix and output_path.suffix.lower() in {"", - ".bin"}: - if output_path.suffix.lower() != suffix.lower(): - output_path = output_path.with_suffix(suffix) - target_dir = output_path.parent - # Determine temp directory (prefer provided tmp_dir, fallback to output location) - temp_dir = Path(getattr(args, "tmp_dir", None) or target_dir) - try: - ensure_directory(temp_dir) - except RuntimeError: - temp_dir = target_dir - temp_file = tempfile.NamedTemporaryFile( - delete=False, - suffix=suffix, - dir=str(temp_dir) - ) - temp_path = Path(temp_file.name) - temp_file.close() - downloaded_bytes = 0 - headers = { - "Hydrus-Client-API-Access-Key": args.access_key, - } - try: - downloaded_bytes = download_hydrus_file( - args.file_url, - headers, - temp_path, - args.timeout - ) - if os.environ.get("DOWNLOW_DEBUG"): - log(f"hydrus-export downloaded {downloaded_bytes} bytes", file=sys.stderr) - except httpx.RequestError as exc: - if temp_path.exists(): - temp_path.unlink() - log(f"hydrus-export download failed: {exc}", file=sys.stderr) - return 1 - except Exception as exc: # pragma: no cover - unexpected - if temp_path.exists(): - temp_path.unlink() - log(f"hydrus-export error: {exc}", file=sys.stderr) - return 1 - ffmpeg_log: Optional[str] = None - converted_tmp: Optional[Path] = None - try: - final_target = unique_path(output_path) - if args.format == "copy": - shutil.move(str(temp_path), str(final_target)) - result_path = final_target - else: - ffmpeg_path = shutil.which("ffmpeg") - if not ffmpeg_path: - raise RuntimeError("ffmpeg executable not found in PATH") - converted_tmp = final_target.with_suffix(final_target.suffix + ".part") - if converted_tmp.exists(): - converted_tmp.unlink() - max_width = args.max_width if args.max_width and args.max_width > 0 else 0 - cmd = build_ffmpeg_command( - ffmpeg_path, - temp_path, - converted_tmp, - args.format, - max_width, - metadata=ffmpeg_metadata if ffmpeg_metadata else None, - ) - if os.environ.get("DOWNLOW_DEBUG"): - log(f"ffmpeg command: {' '.join(cmd)}", file=sys.stderr) - completed = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - text=True, - ) - ffmpeg_log = (completed.stderr or "").strip() - if completed.returncode != 0: - error_details = ffmpeg_log or (completed.stdout or "").strip() - raise RuntimeError( - f"ffmpeg failed with exit code {completed.returncode}" + - (f": {error_details}" if error_details else "") - ) - shutil.move(str(converted_tmp), str(final_target)) - result_path = final_target - apply_mutagen_metadata(result_path, ffmpeg_metadata, args.format) - result_size = result_path.stat().st_size if result_path.exists() else None - payload: dict[str, - object] = { - "output": str(result_path) - } - if downloaded_bytes: - payload["source_bytes"] = downloaded_bytes - if result_size is not None: - payload["size_bytes"] = result_size - if metadata_payload: - payload["metadata_keys"] = sorted(ffmpeg_metadata.keys() - ) if ffmpeg_metadata else [] - log(json.dumps(payload, ensure_ascii=False)) - if ffmpeg_log: - log(ffmpeg_log, file=sys.stderr) - return 0 - except Exception as exc: - log(f"hydrus-export failed: {exc}", file=sys.stderr) - return 1 - finally: - if temp_path.exists(): - try: - temp_path.unlink() - except OSError: - pass - if converted_tmp and converted_tmp.exists(): - try: - converted_tmp.unlink() - except OSError: - pass - - -# ============================================================================ -# Hydrus Wrapper Functions - Utilities for client initialization and config -# ============================================================================ -# This section consolidates functions formerly in hydrus_wrapper.py -# Provides: supported filetypes, client initialization, caching, service resolution - -# Official Hydrus supported filetypes -# Source: https://hydrusnetwork.github.io/hydrus/filetypes.html -SUPPORTED_FILETYPES = { - # Images - "image": { - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - ".jxl": "image/jxl", - ".bmp": "image/bmp", - ".heic": "image/heic", - ".heif": "image/heif", - ".ico": "image/x-icon", - ".qoi": "image/qoi", - ".tiff": "image/tiff", - }, - # Animated Images - "animation": { - ".apng": "image/apng", - ".avifs": "image/avif-sequence", - ".heics": "image/heic-sequence", - ".heifs": "image/heif-sequence", - }, - # Video - "video": { - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mkv": "video/x-matroska", - ".avi": "video/x-msvideo", - ".flv": "video/x-flv", - ".mov": "video/quicktime", - ".mpeg": "video/mpeg", - ".ogv": "video/ogg", - ".rm": "video/vnd.rn-realvideo", - ".wmv": "video/x-ms-wmv", - }, - # Audio - "audio": { - ".mp3": "audio/mp3", - ".ogg": "audio/ogg", - ".flac": "audio/flac", - ".m4a": "audio/mp4", - ".mka": "audio/x-matroska", - ".mkv": "audio/x-matroska", - ".mp4": "audio/mp4", - ".ra": "audio/vnd.rn-realaudio", - ".tta": "audio/x-tta", - ".wav": "audio/x-wav", - ".wv": "audio/wavpack", - ".wma": "audio/x-ms-wma", - }, - # Applications & Documents - "application": { - ".swf": "application/x-shockwave-flash", - ".pdf": "application/pdf", - ".epub": "application/epub+zip", - ".djvu": "image/vnd.djvu", - ".docx": - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".pptx": - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - ".doc": "application/msword", - ".xls": "application/vnd.ms-excel", - ".ppt": "application/vnd.ms-powerpoint", - ".rtf": "application/rtf", - }, - # Image Project Files - "project": { - ".clip": "application/clip1", - ".kra": "application/x-krita", - ".procreate": "application/x-procreate1", - ".psd": "image/vnd.adobe.photoshop", - ".sai2": "application/sai21", - ".svg": "image/svg+xml", - ".xcf": "application/x-xcf", - }, - # Archives - "archive": { - ".cbz": "application/vnd.comicbook+zip", - ".7z": "application/x-7z-compressed", - ".gz": "application/gzip", - ".rar": "application/vnd.rar", - ".zip": "application/zip", - }, -} - -# Flatten to get all supported extensions -ALL_SUPPORTED_EXTENSIONS = set(GLOBAL_SUPPORTED_EXTENSIONS) - -# Global Hydrus client cache to reuse session keys -_hydrus_client_cache: dict[str, - Any] = {} - -# Cache Hydrus availability across the session -_HYDRUS_AVAILABLE: Optional[bool] = None -_HYDRUS_UNAVAILABLE_REASON: Optional[str] = None - - -def reset_cache() -> None: - """Reset the availability cache (useful for testing).""" - global _HYDRUS_AVAILABLE, _HYDRUS_UNAVAILABLE_REASON - _HYDRUS_AVAILABLE = None - _HYDRUS_UNAVAILABLE_REASON = None - - -def is_available(config: dict[str, - Any], - use_cache: bool = True) -> tuple[bool, - Optional[str]]: - """Check if Hydrus is available and accessible. - - Performs a lightweight probe to verify: - - At least one configured Hydrus instance has URL + API configured - - A TCP connection to that instance's host/port can be established - - Results are cached per session unless use_cache=False. - - Args: - config: Configuration dict with Hydrus settings - use_cache: If True, use cached result from previous probe - - Returns: - Tuple of (is_available: bool, reason: Optional[str]) - reason is None if available, or an error message if not - """ - global _HYDRUS_AVAILABLE, _HYDRUS_UNAVAILABLE_REASON - - if use_cache and _HYDRUS_AVAILABLE is not None: - return _HYDRUS_AVAILABLE, _HYDRUS_UNAVAILABLE_REASON - - # Use new config helpers first, fallback to old method - from SYS.config import get_hydrus_url, get_hydrus_access_key - - # Collect candidate instances (prioritize 'home') - store_block = (config or {}).get("store") or {} - hydrus_block = store_block.get("hydrusnetwork") if isinstance(store_block, dict) else {} - - candidate_names: list[str] = [] - if isinstance(hydrus_block, dict) and hydrus_block: - # Prefer 'home' first when present for backwards compatibility - names = list(hydrus_block.keys()) - if "home" in names: - candidate_names = ["home"] + [n for n in names if n != "home"] - else: - candidate_names = names - - # If no configured instances, keep previous behavior for clearer message - if not candidate_names: - reason = "Hydrus URL not configured (check config.conf store.hydrusnetwork.home.URL)" - _HYDRUS_AVAILABLE = False - _HYDRUS_UNAVAILABLE_REASON = reason - return False, reason - - timeout_raw = config.get("HydrusNetwork_Request_Timeout") - try: - timeout = float(timeout_raw) if timeout_raw is not None else 5.0 - except (TypeError, ValueError): - timeout = 5.0 - - import socket - from urllib.parse import urlparse - - errors: list[str] = [] - - for name in candidate_names: - url = (get_hydrus_url(config, name) or "").strip() - access_key = get_hydrus_access_key(config, name) or "" - if not url: - errors.append(f"Hydrus URL not configured for instance '{name}'") - continue - if not access_key: - errors.append(f"Hydrus access key not configured for instance '{name}'") - continue - - try: - parsed = urlparse(url) - hostname = parsed.hostname or "localhost" - port = parsed.port or (443 if parsed.scheme == "https" else 80) - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - try: - result = sock.connect_ex((hostname, port)) - if result == 0: - _HYDRUS_AVAILABLE = True - _HYDRUS_UNAVAILABLE_REASON = None - return True, None - errors.append(f"Cannot connect to {hostname}:{port} (instance '{name}')") - finally: - sock.close() - except Exception as exc: - errors.append(str(exc)) - continue - - # No candidate succeeded - _HYDRUS_AVAILABLE = False - reason = "; ".join(errors[:3]) if errors else "No Hydrus instances configured with URL and API" - _HYDRUS_UNAVAILABLE_REASON = reason - return False, reason - - -def is_hydrus_available(config: dict[str, Any]) -> bool: - """Check if Hydrus is available without raising. - - Args: - config: Configuration dict - - Returns: - True if Hydrus is available, False otherwise - """ - available, _ = is_available(config) - return available - - -def get_client(config: dict[str, Any], instance_name: str | None = None) -> HydrusNetwork: - """Create and return a Hydrus client. - - If `instance_name` is provided, return a client for that named instance. - If omitted, prefer the 'home' instance when configured, otherwise fall back - to the first configured Hydrus instance found in `config['store']['hydrusnetwork']`. - - Uses access-key authentication by default (no session key acquisition). - A session key may still be acquired explicitly by calling - `HydrusNetwork.ensure_session_key()`. - - Args: - config: Configuration dict with Hydrus settings - instance_name: Optional named Hydrus instance to use (e.g. 'rpi') - - Returns: - HydrusNetwork client instance - - Raises: - RuntimeError: If Hydrus is not configured or unavailable - """ - # Perform a lightweight availability check first - this checks any configured instance. - available, reason = is_available(config) - # We will still attempt to instantiate a client for a configured instance even if the - # availability probe reported unreachable (this keeps behavior resilient in mixed - # network setups), but if no configured instance exists we'll raise below. - - from SYS.config import get_hydrus_url, get_hydrus_access_key - - chosen_instance: str | None = None - - if instance_name: - chosen_instance = str(instance_name).strip() - # Validate existence of configuration - url_candidate = (get_hydrus_url(config, chosen_instance) or "").strip() - if not url_candidate: - raise RuntimeError(f"Hydrus URL is not configured for instance '{chosen_instance}'") - access_key_candidate = get_hydrus_access_key(config, chosen_instance) or "" - if not access_key_candidate: - raise RuntimeError(f"Hydrus access key is not configured for instance '{chosen_instance}'") - else: - # Determine candidate instances from config and prefer 'home' when present - store_block = (config or {}).get("store") or {} - hydrus_block = store_block.get("hydrusnetwork") if isinstance(store_block, dict) else {} - candidate_names: list[str] = [] - if isinstance(hydrus_block, dict) and hydrus_block: - names = list(hydrus_block.keys()) - if "home" in names: - candidate_names = ["home"] + [n for n in names if n != "home"] - else: - candidate_names = names - - # Try to pick the first instance with URL+API configured - for name in candidate_names: - url_candidate = (get_hydrus_url(config, name) or "").strip() - access_key_candidate = get_hydrus_access_key(config, name) or "" - if url_candidate and access_key_candidate: - chosen_instance = name - break - - # If nothing suitable found in config, fall back to 'home' behavior (for backwards compatibility) - if chosen_instance is None: - hydrus_url = (get_hydrus_url(config, "home") or "").strip() - if not hydrus_url: - raise RuntimeError( - "Hydrus URL is not configured (check config.conf store.hydrusnetwork.home.URL)" - ) - chosen_instance = "home" - - # Now we have a chosen instance name; resolve its URL and access key (these validations are defensive) - hydrus_url = (get_hydrus_url(config, chosen_instance) or "").strip() - access_key = get_hydrus_access_key(config, chosen_instance) or "" - - if not hydrus_url: - raise RuntimeError(f"Hydrus URL is not configured for instance '{chosen_instance}'") - if not access_key: - raise RuntimeError(f"Hydrus access key is not configured for instance '{chosen_instance}'") - - timeout_raw = config.get("HydrusNetwork_Request_Timeout") - try: - timeout = float(timeout_raw) if timeout_raw is not None else 60.0 - except (TypeError, ValueError): - timeout = 60.0 - - # Create cache key from URL, access key, and instance name - cache_key = f"{hydrus_url}#{access_key}#{chosen_instance}" - - # Check if we have a cached client - if cache_key in _hydrus_client_cache: - return _hydrus_client_cache[cache_key] - - # Create new client and cache it - client = HydrusNetwork(hydrus_url, access_key, timeout, instance_name=chosen_instance) - _hydrus_client_cache[cache_key] = client - return client - - return client - - -def get_tag_service_name(config: dict[str, Any]) -> str: - """Get the name of the tag service to use for tagging operations. - - Currently always returns "my tags" to avoid remote service errors. - - Args: - config: Configuration dict (not currently used) - - Returns: - Service name string, typically "my tags" - """ - # Always use 'my tags' to avoid remote service errors - return "my tags" - - -def get_tag_service_key(client: HydrusNetwork, - fallback_name: str = "my tags") -> Optional[str]: - """Get the service key for a named tag service. - - Queries the Hydrus client's services and finds the service key matching - the given name. - - Args: - client: HydrusClient instance - fallback_name: Name of the service to find (e.g., "my tags") - - Returns: - Service key string if found, None otherwise - """ - try: - services = client.get_services() - except Exception: - return None - - if not isinstance(services, dict): - return None - - def _normalize_name(value: Any) -> str: - if isinstance(value, bytes): - try: - return value.decode("utf-8", errors="ignore").strip().lower() - except Exception: - return "" - try: - return str(value or "").strip().lower() - except Exception: - return "" - - def _normalize_service_key(value: Any) -> Optional[str]: - if value is None: - return None - if isinstance(value, bytes): - # Hydrus service keys are raw bytes; API expects hex. - try: - hex_text = value.hex() - except Exception: - return None - return hex_text if (len(hex_text) == 64 and all(ch in "0123456789abcdef" for ch in hex_text)) else None - if isinstance(value, str): - text = value.strip().lower() - if text.startswith("0x"): - text = text[2:] - if len(text) == 64 and all(ch in "0123456789abcdef" for ch in text): - return text - return None - try: - text = str(value).strip().lower() - except Exception: - return None - if text.startswith("0x"): - text = text[2:] - if len(text) == 64 and all(ch in "0123456789abcdef" for ch in text): - return text - return None - - target_name = _normalize_name(fallback_name) - if not target_name: - target_name = "my tags" - - # Hydrus returns services grouped by type; walk all lists and match on name - for group in services.values(): - if not isinstance(group, list): - continue - for item in group: - if not isinstance(item, dict): - continue - name = _normalize_name(item.get("name")) - if name != target_name: - continue - key_raw = item.get("service_key") or item.get("key") - normalized_key = _normalize_service_key(key_raw) - if normalized_key: - return normalized_key - - return None - - -def is_request_error(exc: Exception) -> bool: - """Check if an exception is a Hydrus request error. - - Args: - exc: Exception to check - - Returns: - True if this is a HydrusRequestError - """ - return isinstance(exc, HydrusRequestError) - - -CHUNK_SIZE = 1024 * 1024 # 1 MiB - - -def download_hydrus_file( - file_url: str, - headers: dict[str, - str], - destination: Path, - timeout: float -) -> int: - """Download *file_url* into *destination* returning the byte count with progress bar.""" - from SYS.progress import print_progress, print_final_progress - - downloaded = 0 - start_time = time.time() - last_update = start_time - - # Try to get file size from headers if available - file_size = None - with HTTPClient(timeout=timeout, headers=headers) as client: - response = client.get(file_url) - response.raise_for_status() - - # Try to get size from content-length header - try: - file_size = int(response.headers.get("content-length", 0)) - except (ValueError, TypeError): - file_size = None - - filename = destination.name - - with destination.open("wb") as handle: - for chunk in response.iter_bytes(CHUNK_SIZE): - if not chunk: - break - handle.write(chunk) - downloaded += len(chunk) - - # Update progress every 0.5 seconds if we know total size - if file_size: - now = time.time() - if now - last_update >= 0.5: - elapsed = now - start_time - speed = downloaded / elapsed if elapsed > 0 else 0 - print_progress(filename, downloaded, file_size, speed) - last_update = now - - # Print final progress line if we tracked it - if file_size: - elapsed = time.time() - start_time - print_final_progress(filename, file_size, elapsed) - - return downloaded - - -# ============================================================================ -# Hydrus metadata helpers (moved from SYS.metadata) -# ============================================================================ - - -def _normalize_hash(value: Any) -> str: - candidate = str(value or "").strip().lower() - if not candidate: - raise ValueError("Hydrus hash is required") - if len(candidate) != 64 or any(ch not in "0123456789abcdef" for ch in candidate): - raise ValueError("Hydrus hash must be a 64-character hex string") - return candidate - - -def _normalize_tag(tag: Any) -> Optional[str]: - if tag is None: - return None - if isinstance(tag, str): - candidate = tag.strip() - else: - candidate = str(tag).strip() - return candidate or None - - -def _dedup_tags_by_namespace(tags: List[str], keep_first: bool = True) -> List[str]: - if not tags: - return [] - - namespace_to_tags: Dict[Optional[str], List[Tuple[int, str]]] = {} - first_appearance: Dict[Optional[str], int] = {} - - for idx, tag in enumerate(tags): - namespace: Optional[str] = tag.split(":", 1)[0] if ":" in tag else None - if namespace not in first_appearance: - first_appearance[namespace] = idx - if namespace not in namespace_to_tags: - namespace_to_tags[namespace] = [] - namespace_to_tags[namespace].append((idx, tag)) - - result: List[Tuple[int, str]] = [] - for namespace, tag_list in namespace_to_tags.items(): - chosen_tag = tag_list[0][1] if keep_first else tag_list[-1][1] - result.append((first_appearance[namespace], chosen_tag)) - - result.sort(key=lambda x: x[0]) - return [tag for _, tag in result] - - -def _extract_tag_services(entry: Dict[str, Any]) -> List[Dict[str, Any]]: - tags_section = entry.get("tags") - services: List[Dict[str, Any]] = [] - if not isinstance(tags_section, dict): - return services - names_map = tags_section.get("service_keys_to_names") - if not isinstance(names_map, dict): - names_map = {} - - def get_record(service_key: Optional[str], service_name: Optional[str]) -> Dict[str, Any]: - key_lower = service_key.lower() if isinstance(service_key, str) else None - name_lower = service_name.lower() if isinstance(service_name, str) else None - for record in services: - existing_key = record.get("service_key") - if key_lower and isinstance(existing_key, str) and existing_key.lower() == key_lower: - if service_name and not record.get("service_name"): - record["service_name"] = service_name - return record - existing_name = record.get("service_name") - if name_lower and isinstance(existing_name, str) and existing_name.lower() == name_lower: - if service_key and not record.get("service_key"): - record["service_key"] = service_key - return record - record = { - "service_key": service_key, - "service_name": service_name, - "tags": [], - } - services.append(record) - return record - - def _iter_current_status_lists(container: Any) -> Iterable[List[Any]]: - if isinstance(container, dict): - for status_key, tags_list in container.items(): - if str(status_key) != "0": - continue - if isinstance(tags_list, list): - yield tags_list - elif isinstance(container, list): - yield container - - statuses_map = tags_section.get("service_keys_to_statuses_to_tags") - if isinstance(statuses_map, dict): - for service_key, status_map in statuses_map.items(): - record = get_record(service_key if isinstance(service_key, str) else None, names_map.get(service_key)) - for tags_list in _iter_current_status_lists(status_map): - for tag in tags_list: - normalized = _normalize_tag(tag) - if normalized: - record["tags"].append(normalized) - - ignored_keys = { - "service_keys_to_statuses_to_tags", - "service_keys_to_statuses_to_display_tags", - "service_keys_to_display_friendly_tags", - "service_keys_to_names", - "tag_display_types_to_namespaces", - "namespace_display_string_lookup", - "tag_display_decoration_colour_lookup", - } - - for key, service in tags_section.items(): - if key in ignored_keys: - continue - if isinstance(service, dict): - service_key = service.get("service_key") or (key if isinstance(key, str) else None) - service_name = service.get("service_name") or service.get("name") or names_map.get(service_key) - record = get_record(service_key if isinstance(service_key, str) else None, service_name) - storage = service.get("storage_tags") or service.get("statuses_to_tags") or service.get("tags") - if isinstance(storage, dict): - for tags_list in _iter_current_status_lists(storage): - for tag in tags_list: - normalized = _normalize_tag(tag) - if normalized: - record["tags"].append(normalized) - elif isinstance(storage, list): - for tag in storage: - normalized = _normalize_tag(tag) - if normalized: - record["tags"].append(normalized) - - for record in services: - record["tags"] = _dedup_tags_by_namespace(record["tags"], keep_first=True) - return services - - -def _select_primary_tags( - services: List[Dict[str, Any]], - aggregated: List[str], - prefer_service: Optional[str] -) -> Tuple[Optional[str], List[str]]: - prefer_lower = prefer_service.lower() if isinstance(prefer_service, str) else None - if prefer_lower: - for record in services: - name = record.get("service_name") - if isinstance(name, str) and name.lower() == prefer_lower and record["tags"]: - return record.get("service_key"), record["tags"] - for record in services: - if record["tags"]: - return record.get("service_key"), record["tags"] - return None, aggregated - - -def _derive_title( - tags_primary: List[str], - tags_aggregated: List[str], - entry: Dict[str, Any] -) -> Optional[str]: - for source in (tags_primary, tags_aggregated): - for tag in source: - namespace, sep, value = tag.partition(":") - if sep and namespace and namespace.lower() == "title": - cleaned = value.strip() - if cleaned: - return cleaned - for key in ( - "title", - "display_name", - "pretty_name", - "original_display_filename", - "original_filename", - ): - raw_val = entry.get(key) - if isinstance(raw_val, str): - cleaned = raw_val.strip() - if cleaned: - return cleaned - return None - - -def _derive_clip_time( - tags_primary: List[str], - tags_aggregated: List[str], - entry: Dict[str, Any] -) -> Optional[str]: - namespaces = {"clip", "clip_time", "cliptime"} - for source in (tags_primary, tags_aggregated): - for tag in source: - namespace, sep, value = tag.partition(":") - if sep and namespace and namespace.lower() in namespaces: - cleaned = value.strip() - if cleaned: - return cleaned - clip_value = entry.get("clip_time") - if isinstance(clip_value, str): - cleaned_clip = clip_value.strip() - if cleaned_clip: - return cleaned_clip - return None - - -def _summarize_hydrus_entry( - entry: Dict[str, Any], - prefer_service: Optional[str] -) -> Tuple[Dict[str, Any], List[str], Optional[str], Optional[str], Optional[str]]: - services = _extract_tag_services(entry) - aggregated: List[str] = [] - seen: Set[str] = set() - for record in services: - for tag in record["tags"]: - if tag not in seen: - seen.add(tag) - aggregated.append(tag) - service_key, primary_tags = _select_primary_tags(services, aggregated, prefer_service) - title = _derive_title(primary_tags, aggregated, entry) - clip_time = _derive_clip_time(primary_tags, aggregated, entry) - summary = dict(entry) - if title and not summary.get("title"): - summary["title"] = title - if clip_time and not summary.get("clip_time"): - summary["clip_time"] = clip_time - summary["tag_service_key"] = service_key - summary["has_current_file_service"] = _has_current_file_service(entry) - if "is_local" not in summary: - summary["is_local"] = bool(entry.get("is_local")) - return summary, primary_tags, service_key, title, clip_time - - -def _looks_like_hash(value: Any) -> bool: - if not isinstance(value, str): - return False - candidate = value.strip().lower() - return len(candidate) == 64 and all(ch in "0123456789abcdef" for ch in candidate) - - -def _collect_relationship_hashes(payload: Any, accumulator: Set[str]) -> None: - if isinstance(payload, dict): - for value in payload.values(): - _collect_relationship_hashes(value, accumulator) - elif isinstance(payload, (list, tuple, set)): - for value in payload: - _collect_relationship_hashes(value, accumulator) - elif isinstance(payload, str) and _looks_like_hash(payload): - accumulator.add(payload) - - -def _generate_hydrus_url_variants(url: str) -> List[str]: - seen: Set[str] = set() - variants: List[str] = [] - - def push(candidate: Optional[str]) -> None: - if not candidate: - return - text = candidate.strip() - if not text or text in seen: - return - seen.add(text) - variants.append(text) - - push(url) - try: - parsed = urlsplit(url) - except Exception: - return variants - - if parsed.scheme in {"http", "https"}: - alternate_scheme = "https" if parsed.scheme == "http" else "http" - push(urlunsplit((alternate_scheme, parsed.netloc, parsed.path, parsed.query, parsed.fragment))) - - normalized_netloc = parsed.netloc.lower() - if normalized_netloc and normalized_netloc != parsed.netloc: - push(urlunsplit((parsed.scheme, normalized_netloc, parsed.path, parsed.query, parsed.fragment))) - - if parsed.path: - trimmed_path = parsed.path.rstrip("/") - if trimmed_path != parsed.path: - push(urlunsplit((parsed.scheme, parsed.netloc, trimmed_path, parsed.query, parsed.fragment))) - else: - push(urlunsplit((parsed.scheme, parsed.netloc, parsed.path + "/", parsed.query, parsed.fragment))) - unquoted_path = unquote(parsed.path) - if unquoted_path != parsed.path: - push(urlunsplit((parsed.scheme, parsed.netloc, unquoted_path, parsed.query, parsed.fragment))) - - if parsed.query or parsed.fragment: - push(urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))) - if parsed.path: - unquoted_path = unquote(parsed.path) - push(urlunsplit((parsed.scheme, parsed.netloc, unquoted_path, "", ""))) - - return variants - - -def _build_hydrus_query( - hashes: Optional[Sequence[str]], - file_ids: Optional[Sequence[int]], - include_relationships: bool, - minimal: bool, -) -> Dict[str, str]: - query: Dict[str, str] = {} - if hashes: - query["hashes"] = json.dumps([_normalize_hash(h) for h in hashes]) - if file_ids: - query["file_ids"] = json.dumps([int(fid) for fid in file_ids]) - if not query: - raise ValueError("hashes or file_ids must be provided") - query["include_service_keys_to_tags"] = json.dumps(True) - query["include_tag_services"] = json.dumps(True) - query["include_file_services"] = json.dumps(True) - if include_relationships: - query["include_file_relationships"] = json.dumps(True) - if not minimal: - extras = ( - "include_url", - "include_size", - "include_width", - "include_height", - "include_duration", - "include_mime", - "include_has_audio", - "include_is_trashed", - ) - for key in extras: - query[key] = json.dumps(True) - return query - - -def _fetch_hydrus_entries( - client: "HydrusNetwork", - hashes: Optional[Sequence[str]], - file_ids: Optional[Sequence[int]], - include_relationships: bool, - minimal: bool, -) -> List[Dict[str, Any]]: - if not hashes and not file_ids: - return [] - spec = HydrusRequestSpec( - method="GET", - endpoint="/get_files/file_metadata", - query=_build_hydrus_query(hashes, file_ids, include_relationships, minimal), - ) - response = client._perform_request(spec) - metadata = response.get("metadata") if isinstance(response, dict) else None - if isinstance(metadata, list): - return [entry for entry in metadata if isinstance(entry, dict)] - return [] - - -def _has_current_file_service(entry: Dict[str, Any]) -> bool: - services = entry.get("file_services") - if not isinstance(services, dict): - return False - current = services.get("current") - if isinstance(current, dict): - for value in current.values(): - if value: - return True - return False - if isinstance(current, list): - return len(current) > 0 - return False - - -def _compute_file_flags(entry: Dict[str, Any]) -> Tuple[bool, bool, bool]: - mime = entry.get("mime") - mime_lower = mime.lower() if isinstance(mime, str) else "" - is_video = mime_lower.startswith("video/") - is_audio = mime_lower.startswith("audio/") - is_deleted = bool(entry.get("is_trashed")) - file_services = entry.get("file_services") - if not is_deleted and isinstance(file_services, dict): - deleted = file_services.get("deleted") - if isinstance(deleted, dict) and deleted: - is_deleted = True - return is_video, is_audio, is_deleted - - -def fetch_hydrus_metadata(payload: Dict[str, Any]) -> Dict[str, Any]: - hash_hex = None - raw_hash_value = payload.get("hash") - if raw_hash_value is not None: - hash_hex = _normalize_hash(raw_hash_value) - file_ids: List[int] = [] - raw_file_ids = payload.get("file_ids") - if isinstance(raw_file_ids, (list, tuple, set)): - for value in raw_file_ids: - try: - file_ids.append(int(value)) - except (TypeError, ValueError): - continue - elif raw_file_ids is not None: - try: - file_ids.append(int(raw_file_ids)) - except (TypeError, ValueError): - file_ids = [] - raw_file_id = payload.get("file_id") - if raw_file_id is not None: - try: - coerced = int(raw_file_id) - except (TypeError, ValueError): - coerced = None - if coerced is not None and coerced not in file_ids: - file_ids.append(coerced) - base_url = str(payload.get("api_url") or "").strip() - if not base_url: - raise ValueError("Hydrus api_url is required") - access_key = str(payload.get("access_key") or "").strip() - options_raw = payload.get("options") - options = options_raw if isinstance(options_raw, dict) else {} - prefer_service = options.get("prefer_service_name") - if isinstance(prefer_service, str): - prefer_service = prefer_service.strip() - else: - prefer_service = None - include_relationships = bool(options.get("include_relationships")) - minimal = bool(options.get("minimal")) - timeout = float(options.get("timeout") or 60.0) - client = HydrusNetwork(base_url, access_key, timeout) - hashes: Optional[List[str]] = None - if hash_hex: - hashes = [hash_hex] - if not hashes and not file_ids: - raise ValueError("Hydrus hash or file id is required") - try: - entries = _fetch_hydrus_entries( - client, - hashes, - file_ids or None, - include_relationships, - minimal - ) - except HydrusRequestError as exc: - raise RuntimeError(str(exc)) - if not entries: - response: Dict[str, Any] = { - "hash": hash_hex, - "metadata": {}, - "tags": [], - "warnings": [f"No Hydrus metadata for {hash_hex or file_ids}"], - "error": "not_found", - } - if file_ids: - response["file_id"] = file_ids[0] - return response - entry = entries[0] - if not hash_hex: - entry_hash = entry.get("hash") - if isinstance(entry_hash, str) and entry_hash: - hash_hex = entry_hash - hashes = [hash_hex] - summary, primary_tags, service_key, title, clip_time = _summarize_hydrus_entry(entry, prefer_service) - is_video, is_audio, is_deleted = _compute_file_flags(entry) - has_current_file_service = _has_current_file_service(entry) - is_local = bool(entry.get("is_local")) - size_bytes = entry.get("size") or entry.get("file_size") - filesize_mb = None - if isinstance(size_bytes, (int, float)) and size_bytes > 0: - filesize_mb = float(size_bytes) / (1024.0 * 1024.0) - duration = entry.get("duration") - if duration is None and isinstance(entry.get("duration_ms"), (int, float)): - duration = float(entry["duration_ms"]) / 1000.0 - warnings_list: List[str] = [] - if not primary_tags: - warnings_list.append("No tags returned for preferred service") - relationships = None - relationship_metadata: Dict[str, Dict[str, Any]] = {} - if include_relationships and hash_hex: - try: - rel_spec = HydrusRequestSpec( - method="GET", - endpoint="/manage_file_relationships/get_file_relationships", - query={"hash": hash_hex}, - ) - relationships = client._perform_request(rel_spec) - except HydrusRequestError as exc: - warnings_list.append(f"Relationship lookup failed: {exc}") - relationships = None - if isinstance(relationships, dict): - related_hashes: Set[str] = set() - _collect_relationship_hashes(relationships, related_hashes) - related_hashes.discard(hash_hex) - if related_hashes: - try: - related_entries = _fetch_hydrus_entries( - client, - sorted(related_hashes), - None, - False, - True - ) - except HydrusRequestError as exc: - warnings_list.append(f"Relationship metadata fetch failed: {exc}") - else: - for rel_entry in related_entries: - rel_hash = rel_entry.get("hash") - if not isinstance(rel_hash, str): - continue - rel_summary, rel_tags, _, rel_title, rel_clip = _summarize_hydrus_entry(rel_entry, prefer_service) - rel_summary["tags"] = rel_tags - if rel_title: - rel_summary["title"] = rel_title - if rel_clip: - rel_summary["clip_time"] = rel_clip - relationship_metadata[rel_hash] = rel_summary - result: Dict[str, Any] = { - "hash": entry.get("hash") or hash_hex, - "metadata": summary, - "tags": primary_tags, - "tag_service_key": service_key, - "title": title, - "clip_time": clip_time, - "duration": duration, - "filesize_mb": filesize_mb, - "is_video": is_video, - "is_audio": is_audio, - "is_deleted": is_deleted, - "is_local": is_local, - "has_current_file_service": has_current_file_service, - "matched_hash": entry.get("hash") or hash_hex, - "swap_recommended": False, - } - file_id_value = entry.get("file_id") - if isinstance(file_id_value, (int, float)): - result["file_id"] = int(file_id_value) - if relationships is not None: - result["relationships"] = relationships - if relationship_metadata: - result["relationship_metadata"] = relationship_metadata - if warnings_list: - result["warnings"] = warnings_list - return result - - -def fetch_hydrus_metadata_by_url(payload: Dict[str, Any]) -> Dict[str, Any]: - raw_url = payload.get("url") or payload.get("source_url") - url = str(raw_url or "").strip() - if not url: - raise ValueError("URL is required to fetch Hydrus metadata by URL") - base_url = str(payload.get("api_url") or "").strip() - if not base_url: - raise ValueError("Hydrus api_url is required") - access_key = str(payload.get("access_key") or "").strip() - options_raw = payload.get("options") - options = options_raw if isinstance(options_raw, dict) else {} - timeout = float(options.get("timeout") or 60.0) - client = HydrusNetwork(base_url, access_key, timeout) - hashes: Optional[List[str]] = None - file_ids: Optional[List[int]] = None - matched_url = None - normalized_reported = None - seen: Set[str] = set() - queue: deque[str] = deque() - for variant in _generate_hydrus_url_variants(url): - queue.append(variant) - if not queue: - queue.append(url) - tried_variants: List[str] = [] - while queue: - candidate = queue.popleft() - candidate = str(candidate or "").strip() - if not candidate or candidate in seen: - continue - seen.add(candidate) - tried_variants.append(candidate) - spec = HydrusRequestSpec( - method="GET", - endpoint="/add_urls/get_url_files", - query={"url": candidate}, - ) - try: - response = client._perform_request(spec) - except HydrusRequestError as exc: - raise RuntimeError(str(exc)) - response_hashes_list: List[str] = [] - response_file_ids_list: List[int] = [] - if isinstance(response, dict): - normalized_value = response.get("normalized_url") - if isinstance(normalized_value, str): - trimmed = normalized_value.strip() - if trimmed: - normalized_reported = normalized_reported or trimmed - if trimmed not in seen: - queue.append(trimmed) - for redirect_key in ("redirect_url", "url"): - redirect_value = response.get(redirect_key) - if isinstance(redirect_value, str): - redirect_trimmed = redirect_value.strip() - if redirect_trimmed and redirect_trimmed not in seen: - queue.append(redirect_trimmed) - raw_hashes = response.get("hashes") or response.get("file_hashes") - if isinstance(raw_hashes, list): - for item in raw_hashes: - try: - norm_hash = _normalize_hash(item) - except ValueError: - continue - if norm_hash: - response_hashes_list.append(norm_hash) - raw_ids = response.get("file_ids") or response.get("file_id") - if isinstance(raw_ids, list): - for item in raw_ids: - try: - response_file_ids_list.append(int(item)) - except (TypeError, ValueError): - continue - elif raw_ids is not None: - try: - response_file_ids_list.append(int(raw_ids)) - except (TypeError, ValueError): - pass - statuses = response.get("url_file_statuses") - if isinstance(statuses, list): - for entry in statuses: - if not isinstance(entry, dict): - continue - status_hash = entry.get("hash") or entry.get("file_hash") - if status_hash: - norm_status: Optional[str] = None - try: - norm_status = _normalize_hash(status_hash) - except ValueError: - pass - if norm_status: - response_hashes_list.append(norm_status) - status_id = entry.get("file_id") or entry.get("fileid") - if status_id is not None: - try: - response_file_ids_list.append(int(status_id)) - except (TypeError, ValueError): - pass - if not hashes and response_hashes_list: - hashes = response_hashes_list - if not file_ids and response_file_ids_list: - file_ids = response_file_ids_list - if hashes or file_ids: - matched_url = candidate - break - if not hashes and not file_ids: - raise RuntimeError( - "No Hydrus matches for URL variants: " - + ", ".join(tried_variants) - ) - followup_payload = { - "api_url": base_url, - "access_key": access_key, - "hash": hashes[0] if hashes else None, - "file_ids": file_ids, - "options": {"timeout": timeout, "minimal": True}, - } - result = fetch_hydrus_metadata(followup_payload) - result["matched_url"] = matched_url or url - result["normalized_url"] = normalized_reported or matched_url or url - result["tried_urls"] = tried_variants - return result - - -def _build_hydrus_context(payload: Dict[str, Any]) -> Tuple["HydrusNetwork", str, str, float, Optional[str]]: - base_url = str(payload.get("api_url") or "").strip() - if not base_url: - raise ValueError("Hydrus api_url is required") - access_key = str(payload.get("access_key") or "").strip() - options_raw = payload.get("options") - options = options_raw if isinstance(options_raw, dict) else {} - timeout = float(options.get("timeout") or payload.get("timeout") or 60.0) - prefer_service = payload.get("prefer_service_name") or options.get("prefer_service_name") - if isinstance(prefer_service, str): - prefer_service = prefer_service.strip() or None - else: - prefer_service = None - client = HydrusNetwork(base_url, access_key, timeout) - return client, base_url, access_key, timeout, prefer_service - - -def _refetch_hydrus_summary( - base_url: str, - access_key: str, - hash_hex: str, - timeout: float, - prefer_service: Optional[str] -) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "hash": hash_hex, - "api_url": base_url, - "access_key": access_key, - "options": { - "minimal": True, - "include_relationships": False, - "timeout": timeout, - }, - } - if prefer_service: - payload["options"]["prefer_service_name"] = prefer_service - return fetch_hydrus_metadata(payload) - - -def apply_hydrus_tag_mutation( - payload: Dict[str, Any], - add: Iterable[Any], - remove: Iterable[Any] -) -> Dict[str, Any]: - client, base_url, access_key, timeout, prefer_service = _build_hydrus_context(payload) - hash_hex = _normalize_hash(payload.get("hash")) - add_list = [_normalize_tag(tag) for tag in add if _normalize_tag(tag)] - remove_list = [_normalize_tag(tag) for tag in remove if _normalize_tag(tag)] - if not add_list and not remove_list: - raise ValueError("No tag changes supplied") - service_key = payload.get("service_key") or payload.get("tag_service_key") - summary = None - if not service_key: - summary = _refetch_hydrus_summary(base_url, access_key, hash_hex, timeout, prefer_service) - service_key = summary.get("tag_service_key") - if not isinstance(service_key, str) or not service_key: - raise RuntimeError("Unable to determine Hydrus tag service key") - actions: Dict[str, List[str]] = {} - if add_list: - actions["0"] = [tag for tag in add_list if tag] - if remove_list: - actions["1"] = [tag for tag in remove_list if tag] - if not actions: - raise ValueError("Tag mutation produced no actionable changes") - request_payload = { - "hashes": [hash_hex], - "service_keys_to_actions_to_tags": { - service_key: actions, - }, - } - try: - tag_spec = HydrusRequestSpec( - method="POST", - endpoint="/add_tags/add_tags", - data=request_payload, - ) - client._perform_request(tag_spec) - except HydrusRequestError as exc: - raise RuntimeError(str(exc)) - summary_after = _refetch_hydrus_summary(base_url, access_key, hash_hex, timeout, prefer_service) - result = dict(summary_after) - result["added_tags"] = actions.get("0", []) - result["removed_tags"] = actions.get("1", []) - result["tag_service_key"] = summary_after.get("tag_service_key") - return result diff --git a/CLI.py b/CLI.py index b86695b..e4feb34 100644 --- a/CLI.py +++ b/CLI.py @@ -131,7 +131,7 @@ def _send_mpv_ipc_command( return False try: - from MPV.mpv_ipc import MPVIPCClient, get_ipc_pipe_path + from plugins.mpv.mpv_ipc import MPVIPCClient, get_ipc_pipe_path client = MPVIPCClient( socket_path=str(ipc_path or get_ipc_pipe_path()), @@ -2179,7 +2179,7 @@ Come to love it when others take what you share, as there is no greater joy try: try: - from MPV.mpv_ipc import MPV + from plugins.mpv.mpv_ipc import MPV import shutil MPV() @@ -2272,7 +2272,7 @@ Come to love it when others take what you share, as there is no greater joy if _has_store_subtype(config, "debrid"): try: from SYS.config import get_debrid_api_key - from API.alldebrid import AllDebridClient + from plugins.alldebrid.api import AllDebridClient api_key = get_debrid_api_key(config) if not api_key: diff --git a/MPV/__init__.py b/MPV/__init__.py index dd2f442..7a3fd1d 100644 --- a/MPV/__init__.py +++ b/MPV/__init__.py @@ -1,5 +1,3 @@ -from MPV.mpv_ipc import MPV +from plugins.mpv import MPV -__all__ = [ - "MPV", -] +__all__ = ["MPV"] \ No newline at end of file diff --git a/MPV/format_probe.py b/MPV/format_probe.py index dae2acc..804b139 100644 --- a/MPV/format_probe.py +++ b/MPV/format_probe.py @@ -1,48 +1,6 @@ -from __future__ import annotations - -import contextlib -import io -import json -import sys -from pathlib import Path -from typing import Any, Dict - -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) - - -def main(argv: list[str] | None = None) -> int: - args = list(sys.argv[1:] if argv is None else argv) - if not args: - payload: Dict[str, Any] = { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing url", - "table": None, - } - print(json.dumps(payload, ensure_ascii=False)) - return 2 - - url = str(args[0] or "").strip() - captured_stdout = io.StringIO() - captured_stderr = io.StringIO() - with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr): - from MPV.pipeline_helper import _run_op - - payload = _run_op("ytdlp-formats", {"url": url}) - - noisy_stdout = captured_stdout.getvalue().strip() - noisy_stderr = captured_stderr.getvalue().strip() - if noisy_stdout: - payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout])) - if noisy_stderr: - payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr])) - - print(json.dumps(payload, ensure_ascii=False)) - return 0 if payload.get("success") else 1 +from plugins.mpv.format_probe import * +from plugins.mpv.format_probe import main as _main if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(_main()) \ No newline at end of file diff --git a/MPV/lyric.py b/MPV/lyric.py index 91565c0..d5e83c4 100644 --- a/MPV/lyric.py +++ b/MPV/lyric.py @@ -1,2023 +1,6 @@ -r"""Timed lyric overlay for mpv via JSON IPC. - -This is intentionally implemented from scratch (no vendored/copied code) while -providing the same *kind* of functionality as popular mpv lyric scripts: -- Parse LRC (timestamped lyrics) -- Track mpv playback time via IPC -- Show the current line on mpv's OSD - -Primary intended usage in this repo: -- Auto mode (no stdin / no --lrc): loads lyrics from store notes. - A lyric note is stored under the note name 'lyric'. -- If the lyric note is missing, auto mode will attempt to auto-fetch synced lyrics - from a public API (LRCLIB) and store it into the 'lyric' note. - You can disable this by setting config key `lyric_autofetch` to false. -- You can still pipe LRC into this script (stdin) and it will render lyrics in mpv. - -Example (PowerShell): - Get-Content .\song.lrc | python -m MPV.lyric - -If you want to connect to a non-default mpv IPC server: - Get-Content .\song.lrc | python -m MPV.lyric --ipc "\\.\pipe\mpv-custom" -""" - -from __future__ import annotations - -import argparse -import bisect -import hashlib -import json -import os -import re -import sys -import tempfile -import time -from dataclasses import dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Optional, TextIO -from urllib.parse import parse_qs, unquote, urlencode -from urllib.request import Request, urlopen -from urllib.parse import urlparse - -from MPV.mpv_ipc import MPV, MPVIPCClient - -_TIMESTAMP_RE = re.compile(r"\[(?P\d+):(?P\d{2})(?:\.(?P\d{1,3}))?\]") -_OFFSET_RE = re.compile(r"^\[offset:(?P[+-]?\d+)\]$", re.IGNORECASE) -_HASH_RE = re.compile(r"[0-9a-f]{64}", re.IGNORECASE) -_HYDRUS_HASH_QS_RE = re.compile(r"hash=([0-9a-f]{64})", re.IGNORECASE) - -_WIN_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]") -_WIN_UNC_RE = re.compile(r"^\\\\") - -_LOG_FH: Optional[TextIO] = None -_SINGLE_INSTANCE_LOCK_FH: Optional[TextIO] = None - -_LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible" - -# Optional overrides set by the playlist controller (.pipe/.mpv) so the lyric -# helper can resolve notes even when the local file path cannot be mapped back -# to a store via the store DB. -_ITEM_STORE_PROP = "user-data/medeia-item-store" -_ITEM_HASH_PROP = "user-data/medeia-item-hash" -_LEGACY_SUB_TRACK_TITLES = ("medeia-note-sub", "medeia-lyric-sub", "medeia-sub") - -# Note: We previously used `osd-overlay`, but some mpv builds return -# error='invalid parameter' for that command. We now use `show-text`, which is -# widely supported across mpv versions. - -_OSD_STYLE_SAVED: Optional[Dict[str, Any]] = None -_OSD_STYLE_APPLIED: bool = False -_NOTES_CACHE_VERSION = 1 -_DEFAULT_NOTES_CACHE_TTL_S = 900.0 -_DEFAULT_NOTES_CACHE_WAIT_S = 1.5 -_DEFAULT_NOTES_PENDING_WAIT_S = 12.0 -_SUBTITLE_NOTE_ALIASES = ("subtitle", "subtitles", "transcript", "transcription") - - -def _single_instance_lock_path(ipc_path: str) -> Path: - # Key the lock to the mpv IPC target so multiple mpv instances with different - # IPC servers can still run independent lyric helpers. - key = hashlib.sha1((ipc_path or "").encode("utf-8", errors="ignore")).hexdigest() - tmp_dir = Path(tempfile.gettempdir()) - return (tmp_dir / f"medeia-mpv-lyric-{key}.lock").resolve() - - -def _acquire_single_instance_lock(ipc_path: str) -> bool: - """Ensure only one MPV.lyric process runs per IPC server. - - This prevents duplicate overlays (e.g. multiple lyric helpers racing to update OSD). - """ - global _SINGLE_INSTANCE_LOCK_FH - - if _SINGLE_INSTANCE_LOCK_FH is not None: - return True - - lock_path = _single_instance_lock_path(ipc_path) - lock_path.parent.mkdir(parents=True, exist_ok=True) - - try: - fh = open(lock_path, "a", encoding="utf-8", errors="replace") - except Exception: - # If we can't create the lock file, don't block playback; just proceed. - return True - - try: - if os.name == "nt": - import msvcrt - - # Lock the first byte (non-blocking). - msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) - else: - import fcntl - - fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - - _SINGLE_INSTANCE_LOCK_FH = fh - try: - fh.write(f"pid={os.getpid()} ipc={ipc_path}\n") - fh.flush() - except Exception: - pass - return True - except Exception: - try: - fh.close() - except Exception: - pass - return False - - -def _ass_escape(text: str) -> str: - # Escape braces/backslashes so lyric text can't break ASS formatting. - t = str(text or "") - t = t.replace("\\", "\\\\") - t = t.replace("{", "\\{") - t = t.replace("}", "\\}") - t = t.replace("\r\n", "\n").replace("\r", "\n") - t = t.replace("\n", "\\N") - return t - - -def _osd_set_text(client: MPVIPCClient, text: str, *, duration_ms: int = 1000) -> Optional[dict]: - # Signature: show-text [] [] - # Duration 0 clears immediately; we generally set it to cover until next update. - try: - d = int(duration_ms) - except Exception: - d = 1000 - if d < 0: - d = 0 - return client.send_command({ - "command": [ - "show-text", - str(text or ""), - d, - ] - }) - - -def _osd_clear(client: MPVIPCClient) -> None: - try: - _osd_set_text(client, "", duration_ms=0) - except Exception: - return - - -def _log(msg: str) -> None: - line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}" - try: - if _LOG_FH is not None: - _LOG_FH.write(line + "\n") - _LOG_FH.flush() - return - except Exception: - pass - - print(line, file=sys.stderr, flush=True) - - -def _ipc_get_property( - client: MPVIPCClient, - name: str, - default: object = None, - *, - raise_on_disconnect: bool = False, -) -> object: - try: - resp = client.send_command({ - "command": ["get_property", - name] - }) - except Exception as exc: - if raise_on_disconnect: - raise ConnectionError(f"Lost mpv IPC connection: {exc}") from exc - return default - if resp is None: - if raise_on_disconnect: - raise ConnectionError("Lost mpv IPC connection") - return default - if resp and resp.get("error") == "success": - return resp.get("data", default) - return default - - -def _ipc_set_property(client: MPVIPCClient, name: str, value: Any) -> bool: - resp = client.send_command({ - "command": ["set_property", - name, - value] - }) - return bool(resp and resp.get("error") == "success") - - -def _osd_capture_style(client: MPVIPCClient) -> Dict[str, Any]: - keys = [ - "osd-align-x", - "osd-align-y", - "osd-font-size", - "osd-margin-y", - ] - out: Dict[str, Any] = {} - for k in keys: - try: - out[k] = _ipc_get_property(client, k, None) - except Exception: - out[k] = None - return out - - -def _osd_apply_lyric_style(client: MPVIPCClient, *, config: Dict[str, Any]) -> None: - """Apply bottom-center + larger font for lyric show-text messages. - - This modifies mpv's global OSD settings, so we save and restore them. - """ - global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED - - if not _OSD_STYLE_APPLIED: - if _OSD_STYLE_SAVED is None: - _OSD_STYLE_SAVED = _osd_capture_style(client) - - try: - _ipc_set_property(client, "osd-align-x", "center") - _ipc_set_property(client, "osd-align-y", "bottom") - - scale = config.get("lyric_osd_font_scale", 1.15) - try: - scale_f = float(scale) - except Exception: - scale_f = 1.15 - if scale_f < 1.0: - scale_f = 1.0 - - old_size = None - try: - if _OSD_STYLE_SAVED is not None: - old_size = _OSD_STYLE_SAVED.get("osd-font-size") - except Exception: - old_size = None - if isinstance(old_size, (int, float)): - new_size = int(max(10, round(float(old_size) * scale_f))) - else: - # mpv default is typically ~55; choose a conservative readable size. - new_size = int(config.get("lyric_osd_font_size", 64)) - - _ipc_set_property(client, "osd-font-size", new_size) - - min_margin_y = int(config.get("lyric_osd_min_margin_y", 60)) - old_margin_y = None - try: - if _OSD_STYLE_SAVED is not None: - old_margin_y = _OSD_STYLE_SAVED.get("osd-margin-y") - except Exception: - old_margin_y = None - if isinstance(old_margin_y, (int, float)): - _ipc_set_property(client, "osd-margin-y", int(max(old_margin_y, min_margin_y))) - else: - _ipc_set_property(client, "osd-margin-y", min_margin_y) - except Exception: - return - - _OSD_STYLE_APPLIED = True - - -def _osd_restore_style(client: MPVIPCClient) -> None: - global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED - - if not _OSD_STYLE_APPLIED: - return - - try: - saved = _OSD_STYLE_SAVED or {} - for k, v in saved.items(): - if v is None: - continue - try: - _ipc_set_property(client, k, v) - except Exception: - pass - finally: - _OSD_STYLE_APPLIED = False - - -def _osd_clear_and_restore(client: MPVIPCClient) -> None: - """Clear OSD text and restore any saved OSD style in a single call.""" - _osd_clear(client) - _osd_restore_style(client) - - -def _http_get_json_raw(url: str, *, timeout_s: float = 10.0) -> Optional[Any]: - """HTTP GET and JSON-decode; returns the parsed value (dict, list, etc.) or None on any failure.""" - try: - req = Request( - url, - headers={ - "User-Agent": "medeia-macina/lyric", - "Accept": "application/json", - }, - method="GET", - ) - with urlopen(req, timeout=timeout_s) as resp: - data = resp.read() - import json - return json.loads(data.decode("utf-8", errors="replace")) - except Exception as exc: - _log(f"HTTP JSON failed: {exc} ({url})") - return None - - -def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]: - """HTTP GET returning a JSON object (dict), or None.""" - obj = _http_get_json_raw(url, timeout_s=timeout_s) - return obj if isinstance(obj, dict) else None - - -def _http_get_json_list(url: str, *, timeout_s: float = 10.0) -> Optional[list]: - """HTTP GET returning a JSON array (list), or None.""" - obj = _http_get_json_raw(url, timeout_s=timeout_s) - return obj if isinstance(obj, list) else None - - -def _sanitize_query(s: Optional[str]) -> Optional[str]: - if not isinstance(s, str): - return None - t = s.strip().strip("\ufeff") - return t if t else None - - -def _infer_artist_title_from_tags( - tags: List[str] -) -> tuple[Optional[str], - Optional[str]]: - artist = None - title = None - for t in tags or []: - ts = str(t) - low = ts.lower() - if low.startswith("artist:") and artist is None: - artist = ts.split(":", 1)[1].strip() or None - elif low.startswith("title:") and title is None: - title = ts.split(":", 1)[1].strip() or None - if artist and title: - break - return _sanitize_query(artist), _sanitize_query(title) - - -def _wrap_plain_lyrics_as_lrc(text: str) -> str: - # Fallback: create a crude LRC that advances every 4 seconds. - # This is intentionally simple and deterministic. - lines = [ln.strip() for ln in (text or "").splitlines()] - lines = [ln for ln in lines if ln] - if not lines: - return "" - out: List[str] = [] - t_s = 0 - for ln in lines: - mm = t_s // 60 - ss = t_s % 60 - out.append(f"[{mm:02d}:{ss:02d}.00]{ln}") - t_s += 4 - return "\n".join(out) + "\n" - - -def _fetch_lrclib( - *, - artist: Optional[str], - title: Optional[str], - duration_s: Optional[float] = None -) -> Optional[str]: - base = "https://lrclib.net/api" - - # Require both artist and title; title-only lookups cause frequent mismatches. - if not artist or not title: - return None - - # Try direct get. - q: Dict[str, - str] = { - "artist_name": artist, - "track_name": title, - } - if isinstance(duration_s, (int, float)) and duration_s and duration_s > 0: - q["duration"] = str(int(duration_s)) - url = f"{base}/get?{urlencode(q)}" - obj = _http_get_json(url) - if isinstance(obj, dict): - synced = obj.get("syncedLyrics") - if isinstance(synced, str) and synced.strip(): - _log("LRCLIB: got syncedLyrics") - return synced - plain = obj.get("plainLyrics") - if isinstance(plain, str) and plain.strip(): - _log("LRCLIB: only plainLyrics; wrapping") - wrapped = _wrap_plain_lyrics_as_lrc(plain) - return wrapped if wrapped.strip() else None - - # Fallback: search using artist+title only. - q_text = f"{artist} {title}" - url = f"{base}/search?{urlencode({'q': q_text})}" - items = _http_get_json_list(url) or [] - for item in items: - if not isinstance(item, dict): - continue - synced = item.get("syncedLyrics") - if isinstance(synced, str) and synced.strip(): - _log("LRCLIB: search hit with syncedLyrics") - return synced - # Plain lyrics fallback from search if available - for item in items: - if not isinstance(item, dict): - continue - plain = item.get("plainLyrics") - if isinstance(plain, str) and plain.strip(): - _log("LRCLIB: search hit only plainLyrics; wrapping") - wrapped = _wrap_plain_lyrics_as_lrc(plain) - return wrapped if wrapped.strip() else None - - return None - - -def _fetch_lyrics_ovh(*, artist: Optional[str], title: Optional[str]) -> Optional[str]: - # Public, no-auth lyrics provider (typically plain lyrics, not time-synced). - if not artist or not title: - return None - try: - # Endpoint uses path segments, so we urlencode each part. - from urllib.parse import quote - - url = f"https://api.lyrics.ovh/v1/{quote(artist)}/{quote(title)}" - obj = _http_get_json(url) - if not isinstance(obj, dict): - return None - lyr = obj.get("lyrics") - if isinstance(lyr, str) and lyr.strip(): - _log("lyrics.ovh: got plain lyrics; wrapping") - wrapped = _wrap_plain_lyrics_as_lrc(lyr) - return wrapped if wrapped.strip() else None - except Exception as exc: - _log(f"lyrics.ovh failed: {exc}") - return None - - -@dataclass(frozen=True) -class LrcLine: - time_s: float - text: str - - -def _frac_to_ms(frac: str) -> int: - # LRC commonly uses centiseconds (2 digits), but can be 1–3 digits. - if not frac: - return 0 - if len(frac) == 3: - return int(frac) - if len(frac) == 2: - return int(frac) * 10 - return int(frac) * 100 - - -def parse_lrc(text: str) -> List[LrcLine]: - """Parse LRC into sorted timestamped lines.""" - offset_ms = 0 - lines: List[LrcLine] = [] - - for raw_line in text.splitlines(): - line = raw_line.strip("\ufeff\r\n") - if not line: - continue - - # Optional global offset. - off_m = _OFFSET_RE.match(line) - if off_m: - try: - offset_ms = int(off_m.group("ms")) - except Exception: - offset_ms = 0 - continue - - matches = list(_TIMESTAMP_RE.finditer(line)) - if not matches: - # Ignore non-timestamp metadata lines like [ar:], [ti:], etc. - continue - - lyric_text = line[matches[-1].end():].strip() - for m in matches: - mm = int(m.group("m")) - ss = int(m.group("s")) - frac = m.group("frac") or "" - ts_ms = (mm * 60 + ss) * 1000 + _frac_to_ms(frac) + offset_ms - if ts_ms < 0: - continue - lines.append(LrcLine(time_s=ts_ms / 1000.0, text=lyric_text)) - - # Sort and de-dupe by timestamp (prefer last non-empty text). - lines.sort(key=lambda x: x.time_s) - deduped: List[LrcLine] = [] - for item in lines: - if deduped and abs(deduped[-1].time_s - item.time_s) < 1e-6: - if item.text: - deduped[-1] = item - else: - deduped.append(item) - return deduped - - -def _read_all_stdin() -> str: - return sys.stdin.read() - - -def _current_index(time_s: float, times: List[float]) -> int: - # Index of last timestamp <= time_s - return bisect.bisect_right(times, time_s) - 1 - - -def _lyric_duration_ms(idx: int, times: List[float], current_t: float) -> int: - """Duration in ms to display the lyric at *idx* — until the next timestamp or a safe maximum.""" - try: - if idx + 1 < len(times): - return int(max(250, min(8000, (times[idx + 1] - current_t) * 1000))) - except Exception: - pass - return 1200 - - -def _format_vtt_timestamp(seconds: float) -> str: - total_ms = max(0, int(round(float(seconds or 0.0) * 1000.0))) - hours = total_ms // 3600000 - minutes = (total_ms // 60000) % 60 - secs = (total_ms // 1000) % 60 - millis = total_ms % 1000 - return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}" - - -def _lrc_entries_to_vtt_text(entries: List[LrcLine]) -> str: - if not entries: - return "WEBVTT\n\n" - - lines: List[str] = ["WEBVTT", ""] - times = [entry.time_s for entry in entries] - for idx, entry in enumerate(entries, start=1): - start_s = max(0.0, float(entry.time_s or 0.0)) - if idx < len(entries): - end_s = max(start_s + 0.25, float(times[idx])) - else: - end_s = start_s + 1.2 - - text = str(entry.text or "").replace("\r\n", "\n").replace("\r", "\n") - cue_text = text if text.strip() else " " - - lines.append(str(idx)) - lines.append(f"{_format_vtt_timestamp(start_s)} --> {_format_vtt_timestamp(end_s)}") - lines.extend(cue_text.split("\n")) - lines.append("") - - return "\n".join(lines) - - -def _unwrap_memory_m3u(text: Optional[str]) -> Optional[str]: - """Extract the real target URL/path from a memory:// M3U payload.""" - if not isinstance(text, str) or not text.startswith("memory://"): - return text - for line in text.splitlines(): - s = line.strip() - if not s or s.startswith("#") or s.startswith("memory://"): - continue - return s - return text - - -def _extract_hash_from_target(target: str) -> Optional[str]: - if not isinstance(target, str): - return None - m = _HYDRUS_HASH_QS_RE.search(target) - if m: - return m.group(1).lower() - - # Fallback: plain hash string - s = target.strip().lower() - if _HASH_RE.fullmatch(s): - return s - return None - - -def _load_config_best_effort() -> dict: - try: - from SYS.config import load_config - - cfg = load_config() - - return cfg if isinstance(cfg, dict) else {} - except Exception: - return {} - - -def _cache_float_config(config: Optional[dict], key: str, default: float) -> float: - try: - raw = (config or {}).get(key) - if raw is None: - return float(default) - value = float(raw) - if value < 0: - return 0.0 - return value - except Exception: - return float(default) - - -def _notes_cache_root() -> Path: - root = Path(tempfile.gettempdir()) / "medeia-mpv-notes" / "cache" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _generated_sub_root() -> Path: - root = Path(tempfile.gettempdir()) / "medeia-mpv-notes" - root.mkdir(parents=True, exist_ok=True) - return root - - -def _notes_cache_key(store: str, file_hash: str) -> str: - return hashlib.sha1( - f"{str(store or '').strip().lower()}:{str(file_hash or '').strip().lower()}".encode( - "utf-8", - errors="ignore", - ) - ).hexdigest() - - -def _notes_cache_path(store: str, file_hash: str) -> Path: - return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.json").resolve() - - -def _notes_pending_path(store: str, file_hash: str) -> Path: - return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.pending").resolve() - - -def _normalize_notes_payload(notes: Any) -> Dict[str, str]: - if not isinstance(notes, dict): - return {} - return { - str(k): str(v or "") - for k, v in notes.items() - if str(k).strip() - } - - -def load_cached_notes( - store: Optional[str], - file_hash: Optional[str], - *, - config: Optional[dict] = None, -) -> Optional[Dict[str, str]]: - if not store or not file_hash: - return None - - path = _notes_cache_path(str(store), str(file_hash)) - if not path.exists(): - return None - - ttl_s = _cache_float_config(config, "lyric_notes_cache_ttl_seconds", _DEFAULT_NOTES_CACHE_TTL_S) - if ttl_s > 0: - try: - age_s = max(0.0, time.time() - float(path.stat().st_mtime)) - if age_s > ttl_s: - return None - except Exception: - return None - - try: - payload = json.loads(path.read_text(encoding="utf-8", errors="replace")) - except Exception: - return None - - if not isinstance(payload, dict): - return None - if int(payload.get("version") or 0) != _NOTES_CACHE_VERSION: - return None - - return _normalize_notes_payload(payload.get("notes")) - - -def store_cached_notes( - store: Optional[str], - file_hash: Optional[str], - notes: Any, -) -> bool: - if not store or not file_hash: - return False - - normalized = _normalize_notes_payload(notes) - path = _notes_cache_path(str(store), str(file_hash)) - tmp_path = path.with_suffix(".tmp") - payload = { - "version": _NOTES_CACHE_VERSION, - "saved_at": time.time(), - "store": str(store), - "hash": str(file_hash), - "notes": normalized, - } - - try: - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8", - errors="replace", - ) - tmp_path.replace(path) - return True - except Exception: - return False - - -def set_notes_prefetch_pending( - store: Optional[str], - file_hash: Optional[str], - pending: bool, -) -> None: - if not store or not file_hash: - return - - path = _notes_pending_path(str(store), str(file_hash)) - if pending: - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(str(time.time()), encoding="utf-8", errors="replace") - except Exception: - return - return - - try: - if path.exists(): - path.unlink() - except Exception: - return - - -def is_notes_prefetch_pending( - store: Optional[str], - file_hash: Optional[str], - *, - stale_after_s: float = 60.0, -) -> bool: - if not store or not file_hash: - return False - - path = _notes_pending_path(str(store), str(file_hash)) - if not path.exists(): - return False - - try: - age_s = max(0.0, time.time() - float(path.stat().st_mtime)) - if stale_after_s > 0 and age_s > stale_after_s: - path.unlink(missing_ok=True) - return False - except Exception: - return False - - return True - - -def _infer_artist_title_from_mpv(client: MPVIPCClient) -> tuple[Optional[str], Optional[str]]: - artist = None - title = None - - artist_keys = [ - "metadata/by-key/artist", - "metadata/by-key/Artist", - "metadata/by-key/album_artist", - "metadata/by-key/ALBUMARTIST", - ] - title_keys = [ - "metadata/by-key/title", - "metadata/by-key/Title", - "media-title", - ] - - for key in artist_keys: - try: - value = _ipc_get_property(client, key, None) - except Exception: - value = None - artist = _sanitize_query(str(value) if isinstance(value, str) else None) - if artist: - break - - for key in title_keys: - try: - value = _ipc_get_property(client, key, None) - except Exception: - value = None - title = _sanitize_query(str(value) if isinstance(value, str) else None) - if title: - break - - return artist, title - - -def _extract_note_text(notes: Dict[str, str], name: str) -> Optional[str]: - """Return stripped text from the note named *name*, or None if absent or blank.""" - if not isinstance(notes, dict) or not notes: - return None - raw = None - for k, v in notes.items(): - if isinstance(k, str) and k.strip() == name: - raw = v - break - if not isinstance(raw, str): - return None - text = raw.strip("\ufeff\r\n") - return text if text.strip() else None - - -def _extract_first_note_text( - notes: Dict[str, str], - names: List[str], - *, - predicate: Optional[Any] = None, -) -> tuple[Optional[str], Optional[str]]: - for name in names: - candidate = _extract_note_text(notes, name) - if not candidate: - continue - if predicate is not None: - try: - if not bool(predicate(candidate)): - continue - except Exception: - continue - return name, candidate - return None, None - - -def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]: - """Return raw LRC text from the note named 'lyric'.""" - return _extract_note_text(notes, "lyric") - - -def _looks_like_subtitle_text(text: str) -> bool: - t = (text or "").lstrip("\ufeff\r\n").lstrip() - if not t: - return False - upper = t.upper() - if upper.startswith("WEBVTT"): - return True - if upper.startswith("[SCRIPT INFO]"): - return True - if "-->" in t: - return True - if re.search(r"(?m)^Dialogue:\s*", t): - return True - return False - - -def _extract_sub_from_notes(notes: Dict[str, str]) -> tuple[Optional[str], Optional[str]]: - """Return (note_name, subtitle_text) from note-backed subtitle/transcript keys.""" - primary = _extract_note_text(notes, "sub") - if primary: - return "sub", primary - return _extract_first_note_text( - notes, - list(_SUBTITLE_NOTE_ALIASES), - predicate=_looks_like_subtitle_text, - ) - - -def _display_note_name(note_name: Optional[str]) -> str: - text = re.sub(r"\s+", " ", str(note_name or "").replace("_", " ")).strip() - if not text: - return "subtitle" - lowered = text.casefold() - if lowered == "lyric": - return "lyrics" - if lowered == "sub": - return "subtitles" - return text - - -def _display_media_title(client: MPVIPCClient) -> Optional[str]: - for key in ("metadata/by-key/title", "metadata/by-key/Title", "media-title"): - try: - value = _ipc_get_property(client, key, None) - except Exception: - value = None - if isinstance(value, str): - text = re.sub(r"\s+", " ", value).strip() - if text: - return text - return None - - -def _generated_subtitle_title(client: MPVIPCClient, *, note_name: Optional[str]) -> str: - note_label = _display_note_name(note_name) - media_title = _display_media_title(client) - if media_title: - title = f"{note_label}: {media_title}" - else: - title = note_label - title = re.sub(r"\s+", " ", title).strip() - return title[:96] if len(title) > 96 else title - - -def _filename_slug(text: Optional[str], *, default: str) -> str: - value = re.sub(r"[^A-Za-z0-9._ -]+", " ", str(text or "")) - value = re.sub(r"\s+", "-", value).strip("- ._") - value = value[:48] - return value or default - - -def _infer_sub_extension(text: str) -> str: - # Best-effort: mpv generally understands SRT/VTT; choose based on content. - t = (text or "").lstrip("\ufeff\r\n").lstrip() - upper = t.upper() - if upper.startswith("WEBVTT"): - return ".vtt" - if upper.startswith("[SCRIPT INFO]") or re.search(r"(?m)^Dialogue:\s*", t): - return ".ass" - if "-->" in t: - # SRT typically uses commas for milliseconds, VTT uses dots. - if re.search(r"\d\d:\d\d:\d\d,\d\d\d\s*-->\s*\d\d:\d\d:\d\d,\d\d\d", t): - return ".srt" - return ".vtt" - return ".vtt" - - -def _write_temp_sub_file(*, key: str, text: str, label: Optional[str] = None) -> Path: - # Write to a content-addressed temp path so updates force mpv reload. - tmp_dir = _generated_sub_root() - - ext = _infer_sub_extension(text) - digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8", - errors="ignore") - ).hexdigest()[:16] - prefix = _filename_slug(label, default="subtitle") - path = (tmp_dir / f"{prefix}-{digest}{ext}").resolve() - path.write_text(text or "", encoding="utf-8", errors="replace") - return path - - -def _subtitle_track_snapshot(client: MPVIPCClient) -> List[Dict[str, Any]]: - raw = _ipc_get_property(client, "track-list", []) - return raw if isinstance(raw, list) else [] - - -def _track_external_sub_path(track: Dict[str, Any]) -> Optional[Path]: - if not isinstance(track, dict): - return None - for key in ("external-filename", "external_filename", "demux-filename", "demux_filename"): - raw = track.get(key) - if not isinstance(raw, str): - continue - text = raw.strip() - if not text: - continue - try: - return Path(text).expanduser().resolve() - except Exception: - return Path(text) - return None - - -def _is_medeia_generated_sub_track(track: Dict[str, Any]) -> bool: - if not isinstance(track, dict): - return False - title = str(track.get("title") or "").strip() - if title in _LEGACY_SUB_TRACK_TITLES: - return True - path = _track_external_sub_path(track) - if path is None: - return False - try: - path.relative_to(_generated_sub_root().resolve()) - return True - except Exception: - return False - - -def _find_medeia_sub_track_ids(client: MPVIPCClient) -> List[int]: - out: List[int] = [] - for track in _subtitle_track_snapshot(client): - if not isinstance(track, dict): - continue - if str(track.get("type") or "") != "sub": - continue - if not _is_medeia_generated_sub_track(track): - continue - try: - track_id = int(track.get("id")) - except Exception: - continue - out.append(track_id) - return out - - -def _log_medeia_sub_tracks(client: MPVIPCClient, reason: str) -> None: - parts: List[str] = [] - for track in _subtitle_track_snapshot(client): - if not isinstance(track, dict): - continue - if str(track.get("type") or "") != "sub": - continue - if not _is_medeia_generated_sub_track(track): - continue - title = str(track.get("title") or "").strip() - source = _track_external_sub_path(track) - parts.append( - f"id={track.get('id')}" - f" title={title!r}" - f" selected={bool(track.get('selected'))}" - f" external={bool(track.get('external'))}" - f" source={source.name if source is not None else ''}" - ) - if parts: - _log(f"Medeia subtitle tracks {reason}: " + " | ".join(parts)) - else: - _log(f"Medeia subtitle tracks {reason}: ") - - -def _remove_medeia_external_subs(client: MPVIPCClient, *, reason: str = "") -> None: - track_ids = _find_medeia_sub_track_ids(client) - if not track_ids: - return - _log(f"Removing Medeia subtitle tracks reason={reason or 'unknown'} ids={track_ids}") - for track_id in track_ids: - try: - client.send_command({ - "command": ["sub-remove", int(track_id)] - }) - except Exception: - continue - _log_medeia_sub_tracks(client, f"after-remove:{reason or 'unknown'}") - - -def _try_add_external_sub(client: MPVIPCClient, path: Path, *, title: str) -> None: - try: - client.send_command( - { - "command": ["sub-add", - str(path), - "select", - str(title or _NOTE_SUB_TRACK_TITLE)] - } - ) - except Exception: - return - - -def _is_stream_target(target: str) -> bool: - """Return True when mpv's 'path' is not a local filesystem file. - - We intentionally treat any URL/streaming scheme as invalid for lyrics in auto mode. - """ - if not isinstance(target, str): - return False - s = target.strip() - if not s: - return False - - # Windows local paths: drive letter or UNC. - if _WIN_DRIVE_RE.match(s) or _WIN_UNC_RE.match(s): - return False - - # Common streaming prefixes. - if s.startswith("http://") or s.startswith("https://"): - return True - - # Generic scheme:// (e.g. ytdl://, edl://, rtmp://, etc.). - if "://" in s: - try: - parsed = urlparse(s) - scheme = (parsed.scheme or "").lower() - if scheme and scheme not in {"file"}: - return True - except Exception: - return True - - return False - - -def _normalize_file_uri_target(target: str) -> str: - """Convert file:// URIs to a local filesystem path string when possible.""" - if not isinstance(target, str): - return target - s = target.strip() - if not s: - return target - if not s.lower().startswith("file://"): - return target - - try: - parsed = urlparse(s) - path = unquote(parsed.path or "") - - if os.name == "nt": - # UNC: file://server/share/path -> \\server\share\path - if parsed.netloc: - p = path.replace("/", "\\") - if p.startswith("\\"): - p = p.lstrip("\\") - return f"\\\\{parsed.netloc}\\{p}" if p else f"\\\\{parsed.netloc}" - - # Drive letter: file:///C:/path -> C:/path - if path.startswith("/") and len(path) >= 3 and path[2] == ":": - path = path[1:] - - return path or target - except Exception: - return target - - -def _extract_store_from_url_target(target: str) -> Optional[str]: - """Extract explicit store name from a URL query param `store=...` (if present).""" - if not isinstance(target, str): - return None - s = target.strip() - if not (s.startswith("http://") or s.startswith("https://")): - return None - try: - parsed = urlparse(s) - if not parsed.query: - return None - qs = parse_qs(parsed.query) - raw = qs.get("store", [None])[0] - if isinstance(raw, str) and raw.strip(): - return raw.strip() - except Exception: - return None - return None - - -def _infer_hydrus_store_from_url_target(*, target: str, config: dict) -> Optional[str]: - """Infer a Hydrus store backend by matching the URL prefix to the backend base URL.""" - if not isinstance(target, str): - return None - s = target.strip() - if not (s.startswith("http://") or s.startswith("https://")): - return None - - try: - from Store import Store as StoreRegistry - - reg = StoreRegistry(config, suppress_debug=True) - backends = [(name, reg[name]) for name in reg.list_backends()] - except Exception: - return None - - matches: List[str] = [] - for name, backend in backends: - if type(backend).__name__ != "HydrusNetwork": - continue - base_url = getattr(backend, "_url", None) - if not base_url: - client = getattr(backend, "_client", None) - base_url = getattr(client, "url", None) if client else None - if not base_url: - continue - base = str(base_url).rstrip("/") - if s.startswith(base): - matches.append(name) - - if len(matches) == 1: - return matches[0] - return None - - -def _resolve_store_backend_for_target( - *, - target: str, - file_hash: str, - config: dict, -) -> tuple[Optional[str], - Any]: - """Resolve a store backend for a local mpv target using the store DB. - - A target is considered valid only when: - - target is a local filesystem file - - a backend's get_file(hash) returns a local file path - - that path resolves to the same target path - """ - try: - p = Path(target) - if not p.exists() or not p.is_file(): - return None, None - target_resolved = p.resolve() - except Exception: - return None, None - - try: - from Store import Store as StoreRegistry - - reg = StoreRegistry(config, suppress_debug=True) - backend_names = list(reg.list_backends()) - except Exception: - return None, None - - - - for name in backend_names: - try: - backend = reg[name] - except Exception: - continue - - store_file = None - try: - store_file = backend.get_file(file_hash, config=config) - except TypeError: - try: - store_file = backend.get_file(file_hash) - except Exception: - store_file = None - except Exception: - store_file = None - - if not store_file: - continue - - # Only accept local files; if the backend returns a URL, it's not valid for lyrics. - try: - store_path = Path(str(store_file)).expanduser() - if not store_path.exists() or not store_path.is_file(): - continue - if store_path.resolve() != target_resolved: - continue - except Exception: - continue - - return name, backend - - return None, None - - -def _infer_hash_for_target(target: str) -> Optional[str]: - """Infer SHA256 hash from Hydrus URL query, hash-named local files, or by hashing local file content.""" - h = _extract_hash_from_target(target) - if h: - return h - - try: - p = Path(target) - if not p.exists() or not p.is_file(): - return None - stem = p.stem - if isinstance(stem, str) and _HASH_RE.fullmatch(stem.strip()): - return stem.strip().lower() - from SYS.utils import sha256_file - - return sha256_file(p) - except Exception: - return None - - -@dataclass -class _PlaybackState: - """Mutable per-track resolution state for the auto overlay loop. - - Centralising these variables in one object eliminates the repeated - 15-line 'reset everything + clear OSD + remove sub' block that - previously appeared five times inside run_auto_overlay. - """ - - store_name: Optional[str] = None - file_hash: Optional[str] = None - key: Optional[str] = None - backend: Optional[Any] = None - entries: List[LrcLine] = field(default_factory=list) - times: List[float] = field(default_factory=list) - loaded_key: Optional[str] = None - loaded_mode: Optional[str] = None # 'lyric' | 'sub' | 'lyric-sub' | None - loaded_sub_path: Optional[Path] = None - last_target: Optional[str] = None - fetch_attempt_key: Optional[str] = None - fetch_attempt_at: float = 0.0 - cache_wait_key: Optional[str] = None - cache_wait_started_at: float = 0.0 - cache_wait_next_probe_at: float = 0.0 - - def clear(self, client: MPVIPCClient, *, clear_hash: bool = True) -> None: - """Reset backend resolution and clean up any active OSD / external subtitle. - - Pass ``clear_hash=False`` to preserve *file_hash* when the hash is - still valid but the store lookup failed (e.g. store temporarily - unavailable), so the late-arriving-context fallback can retry later. - """ - self.store_name = None - self.backend = None - self.key = None - if clear_hash: - self.file_hash = None - self.entries = [] - self.times = [] - self.cache_wait_key = None - self.cache_wait_started_at = 0.0 - self.cache_wait_next_probe_at = 0.0 - if self.loaded_key is not None: - _osd_clear_and_restore(client) - self.loaded_key = None - self.loaded_mode = None - if self.loaded_sub_path is not None: - _remove_medeia_external_subs(client, reason="state-clear") - self.loaded_sub_path = None - - -def run_auto_overlay( - *, - mpv: MPV, - poll_s: float = 0.15, - config: Optional[dict] = None -) -> int: - """Auto mode: track mpv's current file and render lyrics (note: 'lyric') or subtitles (note: 'sub'). - - State is managed via :class:`_PlaybackState` to eliminate the repeated - 15-line reset blocks of the previous implementation. - """ - cfg = config or {} - - client = mpv.client() - if not client.connect(): - _log("mpv IPC is not reachable (is mpv running with --input-ipc-server?).") - return 3 - - _log(f"Auto overlay connected (ipc={getattr(mpv, 'ipc_path', None)})") - _remove_medeia_external_subs(client, reason="startup-sweep") - - state = _PlaybackState() - last_idx: Optional[int] = None - last_text: Optional[str] = None - last_visible: Optional[bool] = None - - global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED - - # Import the Store registry once so each track change doesn't re-import the module. - try: - from Store import Store as _StoreRegistry # noqa: PLC0415 - _store_cls: Any = _StoreRegistry - except Exception: - _store_cls = None - - def _make_registry() -> Optional[Any]: - if _store_cls is None: - return None - try: - return _store_cls(cfg, suppress_debug=True) - except Exception: - return None - - while True: - # ---------------------------------------------------------------- - # 1. Read IPC properties; reconnect on disconnect. - # ---------------------------------------------------------------- - try: - visible_raw = _ipc_get_property( - client, _LYRIC_VISIBLE_PROP, True, raise_on_disconnect=True - ) - raw_path = _ipc_get_property(client, "path", None, raise_on_disconnect=True) - except ConnectionError: - _osd_clear_and_restore(client) - try: - client.disconnect() - except Exception: - pass - _OSD_STYLE_SAVED = None - _OSD_STYLE_APPLIED = False - if not client.connect(): - _log("mpv IPC disconnected; exiting MPV.lyric") - return 4 - _remove_medeia_external_subs(client, reason="reconnect-sweep:path") - state.clear(client) - state.last_target = None - last_idx = None - last_text = None - last_visible = None - time.sleep(poll_s) - continue - - # ---------------------------------------------------------------- - # 2. Visibility toggle support. - # ---------------------------------------------------------------- - visible = bool(visible_raw) if isinstance(visible_raw, (bool, int)) else True - if last_visible is None: - last_visible = visible - elif last_visible is True and visible is False: - _osd_clear_and_restore(client) - _remove_medeia_external_subs(client, reason="visibility-off") - state.loaded_sub_path = None - last_idx = None - last_text = None - last_visible = visible - elif last_visible is False and visible is True: - if state.loaded_mode in {"sub", "lyric-sub"} and state.loaded_sub_path is None: - state.loaded_key = None - last_idx = None - last_text = None - last_visible = visible - else: - last_visible = visible - - # ---------------------------------------------------------------- - # 3. Normalise the current playback target. - # ---------------------------------------------------------------- - target = _unwrap_memory_m3u(str(raw_path)) if isinstance(raw_path, str) else None - if isinstance(target, str): - target = _normalize_file_uri_target(target) - - if not isinstance(target, str) or not target: - time.sleep(poll_s) - continue - - is_http = target.startswith("http://") or target.startswith("https://") - - # Non-HTTP streams (ytdl://, edl://, rtmp://, etc.) are never valid for lyrics. - if (not is_http) and _is_stream_target(target): - state.clear(client) - state.last_target = target - time.sleep(poll_s) - continue - - # ---------------------------------------------------------------- - # 4. Read user-data overrides from the playlist controller. - # ---------------------------------------------------------------- - store_override: Optional[str] = None - hash_override: Optional[str] = None - try: - raw_so = _ipc_get_property(client, _ITEM_STORE_PROP, None) - raw_ho = _ipc_get_property(client, _ITEM_HASH_PROP, None) - store_override = str(raw_so).strip() if raw_so else None - hash_override = str(raw_ho).strip().lower() if raw_ho else None - except Exception: - pass - - # ---------------------------------------------------------------- - # 5. Resolve store / hash on target change. - # ---------------------------------------------------------------- - if target != state.last_target: - state.last_target = target - last_idx = None - last_text = None - - _log(f"Target changed: {target}") - - state.file_hash = _infer_hash_for_target(target) - if not state.file_hash: - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - # Reset backend state; user-data override may supply it right away. - state.store_name = None - state.backend = None - state.key = None - state.cache_wait_key = None - state.cache_wait_started_at = 0.0 - state.cache_wait_next_probe_at = 0.0 - - if store_override and (not hash_override or hash_override == state.file_hash): - reg = _make_registry() - if reg is not None: - try: - state.backend = reg[store_override] - state.store_name = store_override - state.key = f"{state.store_name}:{state.file_hash}" - _log( - f"Resolved via mpv override" - f" store={state.store_name!r} hash={state.file_hash!r} valid=True" - ) - except Exception: - state.backend = None - state.store_name = None - state.key = None - - if is_http: - store_from_url = _extract_store_from_url_target(target) - store_name = store_from_url or _infer_hydrus_store_from_url_target( - target=target, config=cfg - ) - if not store_name: - _log("HTTP target has no store mapping; lyrics disabled") - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - reg = _make_registry() - if reg is None: - _log(f"HTTP target store {store_name!r} not available; lyrics disabled") - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - try: - state.backend = reg[store_name] - state.store_name = store_name - except Exception: - _log(f"HTTP target store {store_name!r} not available; lyrics disabled") - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - # Existence check only when store was inferred (not explicit in ?store=…). - # When ?store= is in the URL mpv is already streaming — the file provably exists. - if not store_from_url: - try: - meta = state.backend.get_metadata(state.file_hash, config=cfg) - except Exception: - meta = None - if meta is None: - _log( - f"HTTP target not found in store DB" - f" (store={store_name!r} hash={state.file_hash}); lyrics disabled" - ) - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - state.key = f"{state.store_name}:{state.file_hash}" - _log(f"Resolved store={state.store_name!r} hash={state.file_hash!r} valid=True") - - else: - # Local file: resolve via store DB (skip if user-data already resolved it). - if not state.key or not state.backend: - state.store_name, state.backend = _resolve_store_backend_for_target( - target=target, - file_hash=state.file_hash, - config=cfg, - ) - state.key = ( - f"{state.store_name}:{state.file_hash}" - if state.store_name and state.file_hash else None - ) - - _log( - f"Resolved store={state.store_name!r} hash={state.file_hash!r}" - f" valid={bool(state.key)}" - ) - - if not state.key or not state.backend: - state.clear(client, clear_hash=False) - time.sleep(poll_s) - continue - - # ---------------------------------------------------------------- - # 6. Late-arriving context fallback: user-data override published - # after the track change was already processed without a backend. - # ---------------------------------------------------------------- - if (not is_http) and target and (not state.key or not state.backend): - try: - state.file_hash = _infer_hash_for_target(target) or state.file_hash - except Exception: - pass - - if ( - store_override - and state.file_hash - and (not hash_override or hash_override == state.file_hash) - ): - reg = _make_registry() - if reg is not None: - try: - state.backend = reg[store_override] - state.store_name = store_override - state.key = f"{state.store_name}:{state.file_hash}" - _log( - f"Resolved via mpv override" - f" store={state.store_name!r} hash={state.file_hash!r} valid=True" - ) - except Exception: - pass - - # ---------------------------------------------------------------- - # 7. Load / reload content when the resolved key changes. - # ---------------------------------------------------------------- - if ( - state.key - and state.key != state.loaded_key - and state.store_name - and state.file_hash - and state.backend - ): - notes: Optional[Dict[str, str]] = None - cache_wait_s = _cache_float_config( - cfg, - "lyric_notes_cache_wait_seconds", - _DEFAULT_NOTES_CACHE_WAIT_S, - ) - pending_wait_s = _cache_float_config( - cfg, - "lyric_notes_pending_wait_seconds", - _DEFAULT_NOTES_PENDING_WAIT_S, - ) - - try: - notes = load_cached_notes(state.store_name, state.file_hash, config=cfg) - except Exception: - notes = None - - if notes is None: - now = time.time() - if state.cache_wait_key != state.key: - state.cache_wait_key = state.key - state.cache_wait_started_at = now - state.cache_wait_next_probe_at = 0.0 - elif state.cache_wait_next_probe_at > now: - time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) - continue - pending = is_notes_prefetch_pending(state.store_name, state.file_hash) - waited_s = max(0.0, now - float(state.cache_wait_started_at or now)) - - if pending and waited_s < pending_wait_s: - state.cache_wait_next_probe_at = now + max(0.2, min(0.5, pending_wait_s - waited_s)) - time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) - continue - - if waited_s < cache_wait_s: - state.cache_wait_next_probe_at = now + max(0.2, min(0.5, cache_wait_s - waited_s)) - time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) - continue - - try: - notes = state.backend.get_note(state.file_hash, config=cfg) or {} - except Exception: - notes = {} - - try: - store_cached_notes(state.store_name, state.file_hash, notes) - except Exception: - pass - - state.cache_wait_key = None - state.cache_wait_started_at = 0.0 - state.cache_wait_next_probe_at = 0.0 - - try: - _log( - f"Loaded notes keys:" - f" {sorted(str(k) for k in notes) if isinstance(notes, dict) else 'N/A'}" - ) - except Exception: - _log("Loaded notes keys: ") - - sub_note_name, sub_text = _extract_sub_from_notes(notes) - if sub_text: - # Hand subtitles to mpv's track subsystem; suppress OSD lyric overlay. - _osd_clear_and_restore(client) - sub_path: Optional[Path] = None - sub_title = _generated_subtitle_title(client, note_name=sub_note_name) - try: - sub_path = _write_temp_sub_file(key=state.key, text=sub_text, label=sub_title) - except Exception as exc: - _log(f"Failed to write sub note temp file: {exc}") - - if sub_path is not None: - _remove_medeia_external_subs(client, reason="load-note-sub") - _try_add_external_sub(client, sub_path, title=sub_title) - state.loaded_sub_path = sub_path - _log( - f"Loaded note-backed native subtitle track" - f" note={sub_note_name!r} title={sub_title!r} path={sub_path}" - ) - _log_medeia_sub_tracks(client, "after-add-note-sub") - - state.entries = [] - state.times = [] - state.loaded_key = state.key - state.loaded_mode = "sub" - - else: - # Switching away from native subtitle mode: unload the external subtitle. - if state.loaded_sub_path is not None: - _remove_medeia_external_subs(client, reason="switch-away-native-sub") - state.loaded_sub_path = None - - lrc_text = _extract_lrc_from_notes(notes) - if not lrc_text: - _log("No lyric note found (note name: 'lyric')") - - # Auto-fetch: throttled per key to avoid hammering APIs. - autofetch_enabled = bool(cfg.get("lyric_autofetch", True)) - now = time.time() - if ( - not lrc_text - and - autofetch_enabled - and state.key != state.fetch_attempt_key - and (now - state.fetch_attempt_at) > 2.0 - ): - state.fetch_attempt_key = state.key - state.fetch_attempt_at = now - - artist, title = _infer_artist_title_from_mpv(client) - duration_s: Optional[float] = None - try: - duration_s = _ipc_get_property(client, "duration", None) - except Exception: - pass - if not artist or not title: - try: - tags, _src = state.backend.get_tag(state.file_hash, config=cfg) - if isinstance(tags, list): - artist, title = _infer_artist_title_from_tags( - [str(x) for x in tags] - ) - except Exception: - pass - - _log( - f"Autofetch query artist={artist!r} title={title!r}" - f" duration={duration_s!r}" - ) - - if not artist or not title: - _log("Autofetch skipped: requires both artist and title") - fetched: Optional[str] = None - else: - fetched = _fetch_lrclib( - artist=artist, - title=title, - duration_s=( - float(duration_s) - if isinstance(duration_s, (int, float)) else None - ), - ) - if not fetched or not fetched.strip(): - fetched = _fetch_lyrics_ovh(artist=artist, title=title) - - if fetched and fetched.strip(): - try: - ok = bool( - state.backend.set_note( - state.file_hash, "lyric", fetched, config=cfg - ) - ) - _log(f"Autofetch stored lyric note ok={ok}") - except Exception as exc: - _log(f"Autofetch failed to store lyric note: {exc}") - else: - _log("Autofetch: no lyrics found") - - state.entries = [] - state.times = [] - _osd_clear_and_restore(client) - state.loaded_key = None - state.loaded_mode = None - - elif not lrc_text: - # No lyric and autofetch is throttled or disabled for this key. - _osd_clear_and_restore(client) - state.entries = [] - state.times = [] - state.loaded_key = state.key - state.loaded_mode = None - - else: - _log(f"Loaded lyric note ({len(lrc_text)} chars)") - parsed = parse_lrc(lrc_text) - if not parsed: - _log("Lyric note contained no timestamped entries") - _osd_clear_and_restore(client) - state.entries = [] - state.times = [] - state.loaded_key = state.key - state.loaded_mode = None - else: - lyric_sub_path: Optional[Path] = None - lyric_sub_title = _generated_subtitle_title(client, note_name="lyric") - try: - lyric_sub_text = _lrc_entries_to_vtt_text(parsed) - lyric_sub_path = _write_temp_sub_file( - key=f"{state.key}:lyric", - text=lyric_sub_text, - label=lyric_sub_title, - ) - except Exception as exc: - _log(f"Failed to write lyric note temp subtitle: {exc}") - - if lyric_sub_path is None: - _osd_clear_and_restore(client) - state.entries = [] - state.times = [] - state.loaded_key = state.key - state.loaded_mode = None - else: - _osd_clear_and_restore(client) - _remove_medeia_external_subs(client, reason="load-lyric-sub") - _try_add_external_sub(client, lyric_sub_path, title=lyric_sub_title) - state.loaded_sub_path = lyric_sub_path - state.entries = [] - state.times = [] - state.loaded_key = state.key - state.loaded_mode = "lyric-sub" - _log( - f"Loaded lyric note as native subtitle track" - f" title={lyric_sub_title!r} entries={len(parsed)}" - f" path={lyric_sub_path}" - ) - _log_medeia_sub_tracks(client, "after-add-lyric-sub") - - # ---------------------------------------------------------------- - # 8. Render the current lyric line. - # ---------------------------------------------------------------- - try: - t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) - except ConnectionError: - _osd_clear_and_restore(client) - try: - client.disconnect() - except Exception: - pass - _OSD_STYLE_SAVED = None - _OSD_STYLE_APPLIED = False - if not client.connect(): - _log("mpv IPC disconnected; exiting MPV.lyric") - return 4 - _remove_medeia_external_subs(client, reason="reconnect-sweep:time") - state.clear(client) - state.last_target = None - last_idx = None - last_text = None - last_visible = None - time.sleep(poll_s) - continue - - if not isinstance(t, (int, float)): - time.sleep(poll_s) - continue - - if not state.entries: - if last_text is not None: - _osd_clear_and_restore(client) - last_text = None - last_idx = None - time.sleep(poll_s) - continue - - if not visible: - if last_text is not None: - _osd_clear_and_restore(client) - last_text = None - last_idx = None - time.sleep(poll_s) - continue - - idx = _current_index(float(t), state.times) - if idx < 0: - time.sleep(poll_s) - continue - - line = state.entries[idx] - if idx != last_idx or line.text != last_text: - if state.loaded_mode == "lyric": - try: - _osd_apply_lyric_style(client, config=cfg) - except Exception: - pass - - dur_ms = _lyric_duration_ms(idx, state.times, float(t)) - resp = _osd_set_text(client, line.text, duration_ms=dur_ms) - if resp is None: - client.disconnect() - if not client.connect(): - print("Lost mpv IPC connection.", file=sys.stderr) - return 4 - elif isinstance(resp, dict) and resp.get("error") not in (None, "success"): - _log(f"mpv show-text returned error={resp.get('error')!r}") - last_idx = idx - last_text = line.text - - time.sleep(poll_s) - -def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> int: - if not entries: - print("No timestamped LRC lines found.", file=sys.stderr) - return 2 - - times = [e.time_s for e in entries] - last_idx: Optional[int] = None - last_text: Optional[str] = None - - client = mpv.client() - if not client.connect(): - print( - "mpv IPC is not reachable (is mpv running with --input-ipc-server?).", - file=sys.stderr, - ) - return 3 - - while True: - try: - # mpv returns None when idle/no file. - t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) - except ConnectionError: - _osd_clear(client) - try: - client.disconnect() - except Exception: - pass - if not client.connect(): - print("Lost mpv IPC connection.", file=sys.stderr) - return 4 - time.sleep(poll_s) - continue - - if not isinstance(t, (int, float)): - time.sleep(poll_s) - continue - - idx = _current_index(float(t), times) - if idx < 0: - # Before first lyric timestamp. - time.sleep(poll_s) - continue - - line = entries[idx] - if idx != last_idx or line.text != last_text: - dur_ms = _lyric_duration_ms(idx, times, float(t)) - resp = _osd_set_text(client, line.text, duration_ms=dur_ms) - if resp is None: - client.disconnect() - if not client.connect(): - print("Lost mpv IPC connection.", file=sys.stderr) - return 4 - elif isinstance(resp, dict) and resp.get("error") not in (None, "success"): - _log(f"mpv show-text returned error={resp.get('error')!r}") - last_idx = idx - last_text = line.text - - time.sleep(poll_s) - -def main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser(prog="python -m MPV.lyric", add_help=True) - parser.add_argument( - "--ipc", - default=None, - help="mpv IPC path. Defaults to the repo's fixed IPC pipe name.", - ) - parser.add_argument( - "--lrc", - default=None, - help="Path to an .lrc file. If omitted, reads LRC from stdin.", - ) - parser.add_argument( - "--poll", - type=float, - default=0.15, - help="Polling interval in seconds for time-pos updates.", - ) - parser.add_argument( - "--log", - default=None, - help="Optional path to a log file for diagnostics.", - ) - - args = parser.parse_args(argv) - - # Configure logging early. - global _LOG_FH - if args.log: - try: - log_path = Path(str(args.log)).expanduser().resolve() - log_path.parent.mkdir(parents=True, exist_ok=True) - _LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace") - _log("MPV.lyric starting") - except Exception: - _LOG_FH = None - - mpv = MPV(ipc_path=args.ipc) if args.ipc else MPV() - - # Prevent multiple lyric helpers from running at once for the same mpv IPC. - if not _acquire_single_instance_lock(getattr(mpv, "ipc_path", "") or ""): - _log("Another MPV.lyric instance is already running for this IPC; exiting.") - return 0 - - # If --lrc is provided, use it. - if args.lrc: - with open(args.lrc, "r", encoding="utf-8", errors="replace") as f: - lrc_text = f.read() - entries = parse_lrc(lrc_text) - try: - return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll)) - except KeyboardInterrupt: - return 0 - - # Otherwise: if stdin has content, treat it as LRC; if stdin is empty/TTY, auto-discover. - lrc_text = "" - try: - if not sys.stdin.isatty(): - lrc_text = _read_all_stdin() or "" - except Exception: - lrc_text = "" - - if lrc_text.strip(): - entries = parse_lrc(lrc_text) - try: - return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll)) - except KeyboardInterrupt: - return 0 - - cfg = _load_config_best_effort() - try: - return run_auto_overlay(mpv=mpv, poll_s=float(args.poll), config=cfg) - except KeyboardInterrupt: - return 0 +from plugins.mpv.lyric import * +from plugins.mpv.lyric import main as _main if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(_main()) diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index 48e56b3..87b3204 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -1,1172 +1 @@ -"""MPV IPC client for cross-platform communication. - -This module provides a cross-platform interface to communicate with mpv -using either named pipes (Windows) or Unix domain sockets (Linux/macOS). - -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 -import socket -import subprocess -import sys -import time as _time -import shutil -from pathlib import Path -from typing import Any, Dict, Optional, List, BinaryIO, Tuple, cast - -from SYS.logger import debug - -# Fixed pipe name for persistent MPV connection across all Python sessions -FIXED_IPC_PIPE_NAME = "mpv-medios-macina" -MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua") - -_LYRIC_PROCESS: Optional[subprocess.Popen] = None -_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_pipe_bytes_available(pipe: BinaryIO) -> Optional[int]: - """Return the number of bytes ready to read from a Windows named pipe.""" - if platform.system() != "Windows": - return None - try: - import msvcrt - - handle = msvcrt.get_osfhandle(pipe.fileno()) - kernel32 = ctypes.windll.kernel32 - PeekNamedPipe = kernel32.PeekNamedPipe - PeekNamedPipe.argtypes = [ - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_uint32, - ctypes.c_void_p, - ctypes.POINTER(ctypes.c_uint32), - ctypes.c_void_p, - ] - PeekNamedPipe.restype = ctypes.c_bool - - total_available = ctypes.c_uint32(0) - ok = PeekNamedPipe( - ctypes.c_void_p(handle), - None, - 0, - None, - ctypes.byref(total_available), - None, - ) - if not ok: - return None - return int(total_available.value) - except Exception: - return None - - -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": - return python_exe - try: - exe = str(python_exe or "").strip() - except Exception: - exe = "" - if not exe: - return None - low = exe.lower() - if low.endswith("pythonw.exe"): - return exe - if low.endswith("python.exe"): - try: - candidate = exe[:-10] + "pythonw.exe" - if os.path.exists(candidate): - return candidate - except Exception: - pass - return exe - - -def _windows_hidden_subprocess_kwargs() -> Dict[str, Any]: - """Best-effort kwargs to avoid flashing console windows on Windows. - - Applies to subprocess.run/check_output/Popen. - """ - if platform.system() != "Windows": - return {} - - kwargs: Dict[str, - Any] = {} - try: - create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) - kwargs["creationflags"] = int(create_no_window) - except Exception: - pass - - # Also set startupinfo to hidden, for APIs that honor it. - try: - si = subprocess.STARTUPINFO() - si.dwFlags |= subprocess.STARTF_USESHOWWINDOW - si.wShowWindow = subprocess.SW_HIDE - kwargs["startupinfo"] = si - except Exception: - pass - - return kwargs - - -def _check_mpv_availability() -> Tuple[bool, Optional[str]]: - """Return (available, reason) for the mpv executable. - - This checks that: - - `mpv` is present in PATH - - `mpv --version` can run successfully - - Result is cached per-process to avoid repeated subprocess calls. - """ - global _MPV_AVAILABILITY_CACHE - if _MPV_AVAILABILITY_CACHE is not None: - return _MPV_AVAILABILITY_CACHE - - mpv_path = shutil.which("mpv") - if not mpv_path: - _MPV_AVAILABILITY_CACHE = (False, "Executable 'mpv' not found in PATH") - return _MPV_AVAILABILITY_CACHE - - try: - result = subprocess.run( - [mpv_path, - "--version"], - capture_output=True, - text=True, - timeout=2, - **_windows_hidden_subprocess_kwargs(), - ) - if result.returncode == 0: - _MPV_AVAILABILITY_CACHE = (True, None) - return _MPV_AVAILABILITY_CACHE - _MPV_AVAILABILITY_CACHE = ( - False, - f"MPV returned non-zero exit code: {result.returncode}" - ) - return _MPV_AVAILABILITY_CACHE - except Exception as exc: - _MPV_AVAILABILITY_CACHE = (False, f"Error running MPV: {exc}") - return _MPV_AVAILABILITY_CACHE - - -def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]: - """Return PIDs of `python -m MPV.lyric --ipc ` helpers (Windows only).""" - if platform.system() != "Windows": - return [] - try: - ipc_path = str(ipc_path or "") - except Exception: - ipc_path = "" - if not ipc_path: - return [] - - # Use CIM to query command lines; output as JSON for robust parsing. - # Note: `ConvertTo-Json` returns a number for single item, array for many, or null. - ps_script = ( - "$ipc = " + json.dumps(ipc_path) + "; " - "Get-CimInstance Win32_Process | " - "Where-Object { $_.CommandLine -and $_.CommandLine -match ' -m\\s+MPV\\.lyric(\\s|$)' -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | " - "Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress" - ) - - try: - out = subprocess.check_output( - ["powershell", - "-NoProfile", - "-Command", - ps_script], - stdin=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2, - text=True, - **_windows_hidden_subprocess_kwargs(), - ) - except Exception: - return [] - - txt = (out or "").strip() - if not txt or txt == "null": - return [] - try: - obj = json.loads(txt) - except Exception: - return [] - - pids: List[int] = [] - if isinstance(obj, list): - for v in obj: - try: - pids.append(int(v)) - except Exception: - pass - else: - try: - pids.append(int(obj)) - except Exception: - pass - - # De-dupe and filter obvious junk. - uniq: List[int] = [] - for pid in pids: - if pid and pid > 0 and pid not in uniq: - uniq.append(pid) - return uniq - - -def _windows_kill_pids(pids: List[int]) -> None: - if platform.system() != "Windows": - return - for pid in pids or []: - try: - subprocess.run( - ["taskkill", - "/PID", - str(int(pid)), - "/F"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2, - **_windows_hidden_subprocess_kwargs(), - ) - except Exception: - continue - - -class MPVIPCError(Exception): - """Raised when MPV IPC communication fails.""" - - pass - - -class MPV: - """High-level MPV controller for this app. - - Responsibilities: - - Own the IPC pipe/socket path - - Start MPV with the bundled Lua script - - Query playlist and currently playing item via IPC - - This class intentionally stays "dumb": it does not implement app logic. - App behavior is driven by cmdlet (e.g. `.pipe`) and the bundled Lua script. - """ - - def __init__( - self, - ipc_path: Optional[str] = None, - lua_script_path: Optional[str | Path] = None, - timeout: float = 5.0, - check_mpv: bool = True, - ) -> None: - - if bool(check_mpv): - ok, reason = _check_mpv_availability() - if not ok: - raise MPVIPCError(reason or "MPV unavailable") - - self.timeout = timeout - self.ipc_path = ipc_path or get_ipc_pipe_path() - - if lua_script_path is None: - lua_script_path = MPV_LUA_SCRIPT_PATH - lua_path = Path(str(lua_script_path)).resolve() - self.lua_script_path = str(lua_path) - - def client(self, silent: bool = False) -> "MPVIPCClient": - return MPVIPCClient( - socket_path=self.ipc_path, - timeout=self.timeout, - silent=bool(silent) - ) - - def is_running(self) -> bool: - client = self.client(silent=True) - try: - ok = client.connect() - return bool(ok) - finally: - client.disconnect() - - def send(self, - command: Dict[str, - Any] | List[Any], - silent: bool = False, - wait: bool = True) -> Optional[Dict[str, - Any]]: - client = self.client(silent=bool(silent)) - try: - if not client.connect(): - return None - return client.send_command(command, wait=wait) - except Exception as exc: - if not silent: - debug(f"MPV IPC error: {exc}") - return None - finally: - client.disconnect() - - def get_property(self, name: str, default: Any = None) -> Any: - resp = self.send({ - "command": ["get_property", - name] - }) - if resp and resp.get("error") == "success": - return resp.get("data", default) - return default - - def set_property(self, name: str, value: Any) -> bool: - resp = self.send({ - "command": ["set_property", - name, - value] - }) - return bool(resp and resp.get("error") == "success") - - def download( - self, - *, - url: str, - fmt: str, - store: Optional[str] = None, - path: Optional[str] = None, - ) -> Dict[str, - Any]: - """Download a URL using the same pipeline semantics as the MPV UI. - - This is intended as a stable Python entrypoint for "button actions". - It does not require mpv.exe availability (set check_mpv=False if needed). - """ - url = str(url or "").strip() - fmt = str(fmt or "").strip() - store = str(store or "").strip() if store is not None else None - path = str(path or "").strip() if path is not None else None - - if not url: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing url" - } - if not fmt: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing fmt" - } - if bool(store) == bool(path): - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Provide exactly one of store or path", - } - - # Ensure any in-process cmdlets that talk to MPV pick up this IPC path. - try: - os.environ["MEDEIA_MPV_IPC"] = str(self.ipc_path) - except Exception: - pass - - def _q(s: str) -> str: - return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' - - pipeline = f"download-file -url {_q(url)} -query {_q(f'format:{fmt}')}" - if store: - pipeline += f" | add-file -store {_q(store)}" - else: - pipeline += f" | add-file -path {_q(path or '')}" - - try: - from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 - - runner = PipelineRunner() - result = runner.run_pipeline(pipeline) - return { - "success": bool(getattr(result, - "success", - False)), - "stdout": getattr(result, - "stdout", - "") or "", - "stderr": getattr(result, - "stderr", - "") or "", - "error": getattr(result, - "error", - None), - "pipeline": pipeline, - } - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"{type(exc).__name__}: {exc}", - "pipeline": pipeline, - } - - def get_playlist(self, silent: bool = False) -> Optional[List[Dict[str, Any]]]: - resp = self.send( - { - "command": ["get_property", - "playlist"], - "request_id": 100 - }, - silent=silent - ) - if resp is None: - return None - if resp.get("error") == "success": - data = resp.get("data", []) - return data if isinstance(data, list) else [] - return [] - - def get_now_playing(self) -> Optional[Dict[str, Any]]: - if not self.is_running(): - return None - - playlist = self.get_playlist(silent=True) or [] - pos = self.get_property("playlist-pos", None) - path = self.get_property("path", None) - title = self.get_property("media-title", None) - - effective_path = _unwrap_memory_target(path) if isinstance(path, str) else path - - current_item: Optional[Dict[str, Any]] = None - if isinstance(pos, int) and 0 <= pos < len(playlist): - item = playlist[pos] - current_item = item if isinstance(item, dict) else None - else: - for item in playlist: - if isinstance(item, dict) and item.get("current") is True: - current_item = item - break - - return { - "path": effective_path, - "title": title, - "playlist_pos": pos, - "playlist_item": current_item, - } - - def ensure_lua_loaded(self) -> None: - try: - script_path = self.lua_script_path - if not script_path or not os.path.exists(script_path): - return - # Safe to call repeatedly; mpv will reload the script. - self.send( - { - "command": ["load-script", - script_path], - "request_id": 12 - }, - silent=True - ) - except Exception: - return - - def ensure_lyric_loader_running(self) -> None: - """Start (or keep) the Python lyric overlay helper. - - Uses the fixed IPC pipe name so it can follow playback. - """ - global _LYRIC_PROCESS, _LYRIC_LOG_FH - - # Cross-session guard (Windows): avoid spawning multiple helpers across separate CLI runs. - # Also clean up stale helpers when mpv isn't running anymore. - if platform.system() == "Windows": - try: - existing = _windows_list_lyric_helper_pids(str(self.ipc_path)) - if existing: - if not self.is_running(): - _windows_kill_pids(existing) - return - # If multiple exist, kill them and start fresh (prevents double overlays). - if len(existing) == 1: - return - _windows_kill_pids(existing) - except Exception: - pass - - try: - if _LYRIC_PROCESS is not None and _LYRIC_PROCESS.poll() is None: - return - except Exception: - pass - - try: - if _LYRIC_PROCESS is not None: - try: - _LYRIC_PROCESS.terminate() - except Exception: - pass - finally: - _LYRIC_PROCESS = None - try: - if _LYRIC_LOG_FH is not None: - _LYRIC_LOG_FH.close() - except Exception: - pass - _LYRIC_LOG_FH = None - - try: - try: - tmp_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") - except Exception: - tmp_dir = Path(".") - - # Ensure the module can be imported even when the app is launched from a different cwd. - # Repo root = parent of the MPV package directory. - try: - repo_root = Path(__file__).resolve().parent.parent - except Exception: - repo_root = Path.cwd() - - # Prefer a stable in-repo log so users can inspect it easily. - log_path = None - try: - log_dir = (repo_root / "Log") - log_dir.mkdir(parents=True, exist_ok=True) - log_path = str((log_dir / "medeia-mpv-lyric.log").resolve()) - except Exception: - log_path = None - if not log_path: - log_path = str((tmp_dir / "medeia-mpv-lyric.log").resolve()) - - py = sys.executable - if platform.system() == "Windows": - py = _windows_pythonw_exe(py) or py - - cmd: List[str] = [ - py or "python", - "-m", - "MPV.lyric", - "--ipc", - str(self.ipc_path), - "--log", - log_path, - ] - - # Redirect helper stdout/stderr to the log file so we can see crashes/import errors. - try: - _LYRIC_LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace") - except Exception: - _LYRIC_LOG_FH = None - - kwargs: Dict[str, - Any] = { - "stdin": subprocess.DEVNULL, - "stdout": _LYRIC_LOG_FH or subprocess.DEVNULL, - "stderr": _LYRIC_LOG_FH or subprocess.DEVNULL, - } - - # Ensure immediate flushing to the log file. - env = os.environ.copy() - env["PYTHONUNBUFFERED"] = "1" - try: - existing_pp = env.get("PYTHONPATH") - env["PYTHONPATH"] = ( - str(repo_root) if not existing_pp else - (str(repo_root) + os.pathsep + str(existing_pp)) - ) - except Exception: - pass - kwargs["env"] = env - - # Make the current directory the repo root so `-m MPV.lyric` resolves reliably. - kwargs["cwd"] = str(repo_root) - if platform.system() == "Windows": - # Ensure we don't flash a console window when spawning the helper. - flags = 0 - try: - flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008)) - except Exception: - flags |= 0x00000008 - try: - flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) - except Exception: - flags |= 0x08000000 - kwargs["creationflags"] = flags - kwargs.update( - { - k: v - for k, v in _windows_hidden_subprocess_kwargs().items() - if k != "creationflags" - } - ) - - _LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs) - debug(f"Lyric loader started (log={log_path})") - except Exception as exc: - debug(f"Lyric loader failed to start: {exc}") - - def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool: - for _ in range(max(1, retries)): - client = self.client(silent=True) - try: - if client.connect(): - return True - finally: - client.disconnect() - _time.sleep(delay_seconds) - return False - - def kill_existing_windows(self) -> None: - if platform.system() != "Windows": - return - try: - subprocess.run( - ["taskkill", - "/IM", - "mpv.exe", - "/F"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=2, - **_windows_hidden_subprocess_kwargs(), - ) - except Exception: - return - - def start( - self, - *, - extra_args: Optional[List[str]] = None, - ytdl_raw_options: Optional[str] = None, - http_header_fields: Optional[str] = None, - detached: bool = True, - ) -> None: - # uosc reads its config from "~~/script-opts/uosc.conf". - # With --no-config, mpv makes ~~ expand to an empty string, so uosc can't load. - # Instead, point mpv at a repo-controlled config directory. - try: - repo_root = Path(__file__).resolve().parent.parent - except Exception: - repo_root = Path.cwd() - - portable_config_dir = repo_root / "MPV" / "portable_config" - try: - (portable_config_dir / "script-opts").mkdir(parents=True, exist_ok=True) - except Exception: - pass - - # Ensure uosc.conf is available at the location uosc expects. - try: - # Source uosc.conf is located within the bundled scripts. - src_uosc_conf = repo_root / "MPV" / "portable_config" / "scripts" / "uosc" / "uosc.conf" - dst_uosc_conf = portable_config_dir / "script-opts" / "uosc.conf" - if src_uosc_conf.exists(): - # Only seed a default config if the user doesn't already have one. - if not dst_uosc_conf.exists(): - dst_uosc_conf.write_bytes(src_uosc_conf.read_bytes()) - except Exception: - pass - - cmd: List[str] = [ - "mpv", - f"--config-dir={str(portable_config_dir)}", - "--load-scripts=yes", - "--osc=no", - "--ytdl=yes", - f"--input-ipc-server={self.ipc_path}", - "--idle=yes", - "--force-window=yes", - ] - - # uosc and other scripts are expected to be auto-loaded from portable_config/scripts. - # If --load-scripts=yes is set (standard), mpv will already pick up the loader shim - # at scripts/uosc.lua. We only add a manual --script fallback if that file is missing. - try: - uosc_entry = portable_config_dir / "scripts" / "uosc.lua" - if not uosc_entry.exists() and self.lua_script_path: - lua_dir = Path(self.lua_script_path).resolve().parent - # Check different possible source locations for uosc core. - uosc_paths = [ - portable_config_dir / "scripts" / "uosc" / "scripts" / "uosc" / "main.lua", - lua_dir / "uosc" / "scripts" / "uosc" / "main.lua" - ] - for p in uosc_paths: - if p.exists(): - cmd.append(f"--script={str(p)}") - break - except Exception: - pass - - # Always load the bundled Lua script at startup. - if self.lua_script_path and os.path.exists(self.lua_script_path): - cmd.append(f"--script={self.lua_script_path}") - - if ytdl_raw_options: - cmd.append(f"--ytdl-raw-options={ytdl_raw_options}") - if http_header_fields: - cmd.append(f"--http-header-fields={http_header_fields}") - if extra_args: - cmd.extend([str(a) for a in extra_args if a]) - - kwargs: Dict[str, - Any] = {} - if platform.system() == "Windows": - # Ensure we don't flash a console window when spawning mpv. - flags = 0 - try: - flags |= int( - getattr(subprocess, - "DETACHED_PROCESS", - 0x00000008) - ) if detached else 0 - except Exception: - flags |= 0x00000008 if detached else 0 - try: - flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) - except Exception: - flags |= 0x08000000 - kwargs["creationflags"] = flags - # startupinfo is harmless for GUI apps; helps hide flashes for console-subsystem builds. - kwargs.update( - { - k: v - for k, v in _windows_hidden_subprocess_kwargs().items() - if k != "creationflags" - } - ) - - debug("Starting MPV") - subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - **kwargs, - ) - - -def get_ipc_pipe_path() -> str: - """Get the fixed IPC pipe/socket path for persistent MPV connection. - - Uses a fixed name so all playback sessions connect to the same MPV - window/process instead of creating new instances. - - Returns: - Path to IPC pipe (Windows) or socket (Linux/macOS) - """ - override = os.environ.get("MEDEIA_MPV_IPC") or os.environ.get("MPV_IPC_SERVER") - if override: - return str(override) - - system = platform.system() - - if system == "Windows": - return f"\\\\.\\pipe\\{FIXED_IPC_PIPE_NAME}" - elif system == "Darwin": # macOS - return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" - else: # Linux and others - return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" - - -def _unwrap_memory_target(text: Optional[str]) -> Optional[str]: - """Return the real target from a memory:// M3U payload if present.""" - if not isinstance(text, str) or not text.startswith("memory://"): - return text - for line in text.splitlines(): - line = line.strip() - if not line or line.startswith("#") or line.startswith("memory://"): - continue - return line - return text - - -class MPVIPCClient: - """Client for communicating with mpv via IPC socket/pipe. - - This is the unified interface for all Python code to communicate with mpv. - It handles platform-specific differences (Windows named pipes vs Unix sockets). - """ - - def __init__( - self, - socket_path: Optional[str] = None, - timeout: float = 5.0, - silent: bool = False - ): - """Initialize MPV IPC client. - - Args: - socket_path: Path to IPC socket/pipe. If None, uses the fixed persistent path. - timeout: Socket timeout in seconds. - """ - self.timeout = timeout - self.socket_path = socket_path or get_ipc_pipe_path() - self.sock: socket.socket | BinaryIO | None = None - self.is_windows = platform.system() == "Windows" - self.silent = bool(silent) - self._recv_buffer: bytes = b"" - - def _write_payload(self, payload: str) -> None: - if not self.sock: - if not self.connect(): - raise MPVIPCError("Not connected") - - try: - if self.is_windows: - # BinaryIO pipe from open('\\\\.\\pipe\\...') - pipe = cast(BinaryIO, self.sock) - try: - pipe.write(payload.encode("utf-8")) - pipe.flush() - except OSError as e: - # Windows Errno 22 (EINVAL) often means the pipe handle is now invalid/closed - if getattr(e, "errno", 0) == 22: - raise BrokenPipeError(str(e)) - raise - else: - sock_obj = cast(socket.socket, self.sock) - sock_obj.sendall(payload.encode("utf-8")) - except (OSError, IOError, BrokenPipeError, ConnectionResetError) as exc: - # Pipe became invalid (disconnected, corrupted, etc.). - # Disconnect and attempt one reconnection. - if not self.silent: - debug(f"Pipe write failed: {exc}; attempting reconnect") - self.disconnect() - if self.connect(): - # Retry once after reconnect - try: - if self.is_windows: - pipe = cast(BinaryIO, self.sock) - try: - pipe.write(payload.encode("utf-8")) - pipe.flush() - except OSError as e: - if getattr(e, "errno", 0) == 22: - raise BrokenPipeError(str(e)) - raise - else: - sock_obj = cast(socket.socket, self.sock) - sock_obj.sendall(payload.encode("utf-8")) - except (OSError, IOError, BrokenPipeError, ConnectionResetError) as retry_exc: - self.disconnect() - raise MPVIPCError(f"Pipe write failed after reconnect: {retry_exc}") from retry_exc - else: - raise MPVIPCError("Failed to reconnect after pipe write error") from exc - - def _readline(self, *, timeout: Optional[float] = None) -> Optional[bytes]: - if not self.sock: - if not self.connect(): - return None - - effective_timeout = self.timeout if timeout is None else float(timeout) - deadline = _time.time() + max(0.0, effective_timeout) - - if self.is_windows: - pipe = cast(BinaryIO, self.sock) - while True: - nl = self._recv_buffer.find(b"\n") - if nl != -1: - line = self._recv_buffer[:nl + 1] - self._recv_buffer = self._recv_buffer[nl + 1:] - return line - - remaining = deadline - _time.time() - if remaining <= 0: - return None - - try: - available = _windows_pipe_bytes_available(pipe) - except Exception as exc: - if not self.silent: - debug(f"Pipe availability probe failed: {exc}") - self.disconnect() - return None - - if available is None: - self.disconnect() - return None - - if available <= 0: - _time.sleep(min(0.01, max(0.001, remaining))) - continue - - try: - chunk = pipe.read(min(available, 4096)) - except (OSError, IOError, BrokenPipeError, ConnectionResetError) as exc: - if not self.silent: - debug(f"Pipe readline failed: {exc}") - self.disconnect() - return None - - if not chunk: - return b"" - - self._recv_buffer += chunk - - # Unix: buffer until newline. - sock_obj = cast(socket.socket, self.sock) - while True: - nl = self._recv_buffer.find(b"\n") - if nl != -1: - line = self._recv_buffer[:nl + 1] - self._recv_buffer = self._recv_buffer[nl + 1:] - return line - - remaining = deadline - _time.time() - if remaining <= 0: - return None - - try: - # Temporarily narrow timeout for this read. - old_timeout = sock_obj.gettimeout() - try: - sock_obj.settimeout(remaining) - chunk = sock_obj.recv(4096) - finally: - sock_obj.settimeout(old_timeout) - except socket.timeout: - return None - except Exception: - return None - - if not chunk: - # EOF - return b"" - self._recv_buffer += chunk - - def read_message(self, - *, - timeout: Optional[float] = None) -> Optional[Dict[str, - Any]]: - """Read the next JSON message/event from MPV. - - Returns: - - dict: parsed JSON message/event - - {"event": "__eof__"} if the stream ended - - None on timeout / no data - """ - raw = self._readline(timeout=timeout) - if raw is None: - return None - if raw == b"": - return { - "event": "__eof__" - } - try: - return json.loads(raw.decode("utf-8", errors="replace").strip()) - except Exception: - return None - - def send_command_no_wait(self, - command_data: Dict[str, - Any] | List[Any]) -> Optional[int]: - """Send a command to mpv without waiting for its response. - - This is important for long-running event loops (helpers) so we don't - consume/lose async events (like property-change) while waiting. - """ - try: - request: Dict[str, Any] - if isinstance(command_data, list): - request = { - "command": command_data - } - else: - request = dict(command_data) - - if "request_id" not in request: - request["request_id"] = int(_time.time() * 1000) % 100000 - - payload = json.dumps(request) + "\n" - self._write_payload(payload) - return int(request["request_id"]) - except Exception as exc: - if not self.silent: - debug(f"Error sending no-wait command to MPV: {exc}") - try: - self.disconnect() - except Exception: - pass - return None - - def connect(self) -> bool: - """Connect to mpv IPC socket. - - Returns: - True if connection successful, False otherwise. - """ - 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) - self._recv_buffer = b"" - return True - except (OSError, IOError) as exc: - if not self.silent: - debug(f"Failed to connect to MPV named pipe: {exc}") - return False - else: - # Unix domain socket (Linux, macOS) - if not os.path.exists(self.socket_path): - if not self.silent: - debug(f"IPC socket not found: {self.socket_path}") - return False - - af_unix = getattr(socket, "AF_UNIX", None) - if af_unix is None: - if not self.silent: - debug("IPC AF_UNIX is not available on this platform") - return False - - self.sock = socket.socket(af_unix, socket.SOCK_STREAM) - self.sock.settimeout(self.timeout) - self.sock.connect(self.socket_path) - self._recv_buffer = b"" - return True - except Exception as exc: - if not self.silent: - debug(f"Failed to connect to MPV IPC: {exc}") - self.sock = None - return False - - def send_command(self, - command_data: Dict[str, - Any] | List[Any], - wait: bool = True) -> Optional[Dict[str, - Any]]: - """Send a command to mpv and get response. - - Args: - command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...]) - wait: If True, wait for the command response. - - Returns: - Response dict with 'error' key (value 'success' on success), or None on error. - """ - if not self.sock: - if not self.connect(): - return None - - try: - # Format command as JSON (mpv IPC protocol) - request: Dict[str, Any] - if isinstance(command_data, list): - request = { - "command": command_data - } - else: - request = command_data - - # Add request_id if not present to match response - if "request_id" not in request: - request["request_id"] = int(_time.time() * 1000) % 100000 - - rid = request["request_id"] - payload = json.dumps(request) + "\n" - - # Send command - self._write_payload(payload) - - if not wait: - return {"error": "success", "request_id": rid, "async": True} - - # Receive response - # We need to read lines until we find the one with matching request_id - # or until timeout/error. MPV might send events in between. - start_time = _time.time() - while _time.time() - start_time < self.timeout: - response_data = self._readline(timeout=self.timeout) - if response_data is None: - return None - - if not response_data: - break - - try: - lines = response_data.decode( - "utf-8", - errors="replace" - ).strip().split("\n") - for line in lines: - if not line: - continue - resp = json.loads(line) - - # Check if this is the response to our request - if resp.get("request_id") == request.get("request_id"): - return resp - - # Handle async log messages/events for visibility - event_type = resp.get("event") - if event_type == "log-message": - level = resp.get("level", "info") - prefix = resp.get("prefix", "") - text = resp.get("text", "").strip() - debug(f"[MPV {level}] {prefix} {text}".strip()) - elif event_type: - debug(f"[MPV event] {event_type}: {resp}") - elif "error" in resp and "request_id" not in resp: - debug(f"[MPV error] {resp}") - except json.JSONDecodeError: - pass - - return None - except Exception as exc: - debug(f"Error sending command to MPV: {exc}") - self.disconnect() - return None - - def disconnect(self) -> None: - """Disconnect from mpv IPC socket.""" - if self.sock: - try: - self.sock.close() - except Exception: - pass - self.sock = None - self._recv_buffer = b"" - - def __del__(self) -> None: - """Cleanup on object destruction.""" - self.disconnect() - - def __enter__(self): - """Context manager entry.""" - self.connect() - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.disconnect() +from plugins.mpv.mpv_ipc import * \ No newline at end of file diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 1eeea69..678f9d1 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -1,2160 +1,6 @@ -"""Persistent MPV pipeline helper. - -This process connects to MPV's IPC server, observes a user-data property for -pipeline execution requests, runs the pipeline in-process, and posts results -back to MPV via user-data properties. - -Why: -- Avoid spawning a new Python process for every MPV action. -- Enable MPV Lua scripts to trigger any cmdlet pipeline cheaply. - -Protocol (user-data properties): -- Request: user-data/medeia-pipeline-request (JSON string) - {"id": "...", "pipeline": "...", "seeds": [...]} (seeds optional) -- Response: user-data/medeia-pipeline-response (JSON string) - {"id": "...", "success": bool, "stdout": "...", "stderr": "...", "error": "..."} -- Ready: user-data/medeia-pipeline-ready ("1") - -This helper is intentionally minimal: one request at a time, last-write-wins. -""" - -from __future__ import annotations - -MEDEIA_MPV_HELPER_VERSION = "2026-03-23.1" - -import argparse -import json -import os -import sys -import tempfile -import time -import threading -import logging -import re -import hashlib -import subprocess -import platform -from pathlib import Path -from typing import Any, Callable, Dict, Optional - - -def _repo_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def _runtime_config_root() -> Path: - """Best-effort config root for runtime execution. - - MPV can spawn this helper from an installed location while setting `cwd` to - the repo root (see MPV.mpv_ipc). Prefer `cwd` when it contains `config.conf`. - """ - try: - cwd = Path.cwd().resolve() - if (cwd / "config.conf").exists(): - return cwd - except Exception: - pass - return _repo_root() - - -# Make repo-local packages importable even when mpv starts us from another cwd. -_ROOT = str(_repo_root()) -if _ROOT not in sys.path: - sys.path.insert(0, _ROOT) - -from MPV.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs # noqa: E402 -from SYS.config import load_config, reload_config # noqa: E402 -from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402 -from SYS.repl_queue import enqueue_repl_command # noqa: E402 -from SYS.utils import format_bytes # noqa: E402 -from ProviderCore.registry import get_plugin, get_plugin_class # noqa: E402 -from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402 - -REQUEST_PROP = "user-data/medeia-pipeline-request" -RESPONSE_PROP = "user-data/medeia-pipeline-response" -READY_PROP = "user-data/medeia-pipeline-ready" -VERSION_PROP = "user-data/medeia-pipeline-helper-version" - -OBS_ID_REQUEST = 1001 - -_HELPER_MPV_LOG_EMITTER: Optional[Callable[[str], None]] = None -_HELPER_LOG_BACKLOG: list[str] = [] -_HELPER_LOG_BACKLOG_LIMIT = 200 -_ASYNC_PIPELINE_JOBS: Dict[str, Dict[str, Any]] = {} -_ASYNC_PIPELINE_JOBS_LOCK = threading.Lock() -_ASYNC_PIPELINE_JOB_TTL_SECONDS = 900.0 -_STORE_CHOICES_CACHE: list[str] = [] -_STORE_CHOICES_CACHE_LOCK = threading.Lock() - - -def _normalize_store_choices(values: Any) -> list[str]: - out: list[str] = [] - seen: set[str] = set() - if not isinstance(values, (list, tuple, set)): - return out - for item in values: - text = str(item or "").strip() - if not text: - continue - key = text.casefold() - if key in seen: - continue - seen.add(key) - out.append(text) - return sorted(out, key=str.casefold) - - -def _get_cached_store_choices() -> list[str]: - with _STORE_CHOICES_CACHE_LOCK: - return list(_STORE_CHOICES_CACHE) - - -def _set_cached_store_choices(choices: Any) -> list[str]: - normalized = _normalize_store_choices(choices) - with _STORE_CHOICES_CACHE_LOCK: - _STORE_CHOICES_CACHE[:] = normalized - return list(_STORE_CHOICES_CACHE) - - -def _store_choices_payload(choices: Any) -> Optional[str]: - cached = _normalize_store_choices(choices) - if not cached: - return None - return json.dumps( - { - "success": True, - "choices": cached, - }, - ensure_ascii=False, - ) - - -def _load_store_choices_from_config(*, force_reload: bool = False) -> list[str]: - from Store.registry import list_configured_backend_names # noqa: WPS433 - - cfg = reload_config() if force_reload else load_config() - return _normalize_store_choices(list_configured_backend_names(cfg or {})) - - -def _prune_async_pipeline_jobs(now: Optional[float] = None) -> None: - cutoff = float(now or time.time()) - _ASYNC_PIPELINE_JOB_TTL_SECONDS - with _ASYNC_PIPELINE_JOBS_LOCK: - expired = [ - job_id - for job_id, job in _ASYNC_PIPELINE_JOBS.items() - if float(job.get("updated_at") or 0.0) < cutoff - ] - for job_id in expired: - _ASYNC_PIPELINE_JOBS.pop(job_id, None) - - -def _update_async_pipeline_job(job_id: str, **fields: Any) -> Optional[Dict[str, Any]]: - if not job_id: - return None - - now = time.time() - _prune_async_pipeline_jobs(now) - - with _ASYNC_PIPELINE_JOBS_LOCK: - job = dict(_ASYNC_PIPELINE_JOBS.get(job_id) or {}) - if not job: - job = { - "job_id": job_id, - "status": "queued", - "success": False, - "created_at": now, - } - job.update(fields) - job["updated_at"] = now - _ASYNC_PIPELINE_JOBS[job_id] = job - return dict(job) - - -def _get_async_pipeline_job(job_id: str) -> Optional[Dict[str, Any]]: - if not job_id: - return None - _prune_async_pipeline_jobs() - with _ASYNC_PIPELINE_JOBS_LOCK: - job = _ASYNC_PIPELINE_JOBS.get(job_id) - return dict(job) if job else None - - -def _append_prefixed_log_lines(prefix: str, text: Any, *, max_lines: int = 40) -> None: - payload = str(text or "").replace("\r\n", "\n").replace("\r", "\n") - if not payload.strip(): - return - - emitted = 0 - for line in payload.split("\n"): - line = line.rstrip() - if not line: - continue - _append_helper_log(f"{prefix}{line}") - emitted += 1 - if emitted >= max_lines: - _append_helper_log(f"{prefix}... truncated after {max_lines} lines") - break - - -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: - 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: - pass - stop_event.wait(0.75) - - try: - hb_client.disconnect() - except Exception: - pass - - thread = threading.Thread( - target=_heartbeat_loop, - name="mpv-helper-heartbeat", - daemon=True, - ) - thread.start() - return thread - - -def _windows_list_pipeline_helper_pids(ipc_path: str) -> list[int]: - if platform.system() != "Windows": - return [] - try: - ipc_path = str(ipc_path or "") - except Exception: - ipc_path = "" - if not ipc_path: - return [] - - ps_script = ( - "$ipc = " + json.dumps(ipc_path) + "; " - "Get-CimInstance Win32_Process | " - "Where-Object { $_.CommandLine -and (($_.CommandLine -match 'pipeline_helper\\.py') -or ($_.CommandLine -match ' -m\\s+MPV\\.pipeline_helper(\\s|$)')) -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | " - "Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress" - ) - - try: - out = subprocess.check_output( - ["powershell", "-NoProfile", "-Command", ps_script], - stdin=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=3, - text=True, - **_windows_hidden_subprocess_kwargs(), - ) - except Exception: - return [] - - txt = (out or "").strip() - if not txt or txt == "null": - return [] - - try: - obj = json.loads(txt) - except Exception: - return [] - - raw_pids = obj if isinstance(obj, list) else [obj] - out_pids: list[int] = [] - for value in raw_pids: - try: - pid = int(value) - except Exception: - continue - if pid > 0 and pid not in out_pids: - out_pids.append(pid) - return out_pids - - -def _run_pipeline( - pipeline_text: str, - *, - seeds: Any = None, - json_output: bool = False, -) -> Dict[str, Any]: - # Import after sys.path fix. - from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 - - def _json_safe(value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, dict): - out: Dict[str, Any] = {} - for key, item in value.items(): - out[str(key)] = _json_safe(item) - return out - if isinstance(value, (list, tuple, set)): - return [_json_safe(item) for item in value] - if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")): - try: - return _json_safe(value.to_dict()) - except Exception: - pass - return str(value) - - def _table_to_payload(table: Any) -> Optional[Dict[str, Any]]: - if table is None: - return None - try: - title = getattr(table, "title", "") - except Exception: - title = "" - - rows_payload = [] - try: - rows = getattr(table, "rows", None) - except Exception: - rows = None - if isinstance(rows, list): - for r in rows: - cols_payload = [] - try: - cols = getattr(r, "columns", None) - except Exception: - cols = None - if isinstance(cols, list): - for c in cols: - try: - cols_payload.append( - { - "name": getattr(c, - "name", - ""), - "value": getattr(c, - "value", - ""), - } - ) - except Exception: - continue - - sel_args = None - try: - sel = getattr(r, "selection_args", None) - if isinstance(sel, list): - sel_args = [str(x) for x in sel] - except Exception: - sel_args = None - - rows_payload.append( - { - "columns": cols_payload, - "selection_args": sel_args - } - ) - - # Only return JSON-serializable data (Lua only needs title + rows). - return { - "title": str(title or ""), - "rows": rows_payload - } - - start_time = time.time() - runner = PipelineRunner() - result = runner.run_pipeline(pipeline_text, seeds=seeds) - duration = time.time() - start_time - try: - _append_helper_log( - f"[pipeline] run_pipeline completed in {duration:.2f}s pipeline={pipeline_text[:64]}" - ) - except Exception: - pass - - table_payload = None - try: - table_payload = _table_to_payload(getattr(result, "result_table", None)) - except Exception: - table_payload = None - - data_payload = None - if json_output: - try: - data_payload = _json_safe(getattr(result, "emitted", None) or []) - except Exception: - data_payload = [] - - return { - "success": bool(result.success), - "stdout": result.stdout or "", - "stderr": result.stderr or "", - "error": result.error, - "table": table_payload, - "data": data_payload, - } - - -def _run_pipeline_background( - pipeline_text: str, - *, - seeds: Any, - req_id: str, - job_id: Optional[str] = None, - json_output: bool = False, -) -> None: - def _target() -> None: - try: - if job_id: - _update_async_pipeline_job( - job_id, - status="running", - success=False, - pipeline=pipeline_text, - req_id=req_id, - started_at=time.time(), - finished_at=None, - error=None, - stdout="", - stderr="", - table=None, - data=None, - ) - - result = _run_pipeline( - pipeline_text, - seeds=seeds, - json_output=json_output, - ) - status = "success" if result.get("success") else "failed" - if job_id: - _update_async_pipeline_job( - job_id, - status=status, - success=bool(result.get("success")), - finished_at=time.time(), - error=result.get("error"), - stdout=result.get("stdout", ""), - stderr=result.get("stderr", ""), - table=result.get("table"), - data=result.get("data"), - ) - _append_helper_log( - f"[pipeline async {req_id}] {status}" - + (f" job={job_id}" if job_id else "") - + f" error={result.get('error')}" - ) - _append_prefixed_log_lines( - f"[pipeline async stdout {req_id}] ", - result.get("stdout", ""), - ) - _append_prefixed_log_lines( - f"[pipeline async stderr {req_id}] ", - result.get("stderr", ""), - ) - except Exception as exc: # pragma: no cover - best-effort logging - if job_id: - _update_async_pipeline_job( - job_id, - status="failed", - success=False, - finished_at=time.time(), - error=f"{type(exc).__name__}: {exc}", - stdout="", - stderr="", - table=None, - data=None, - ) - _append_helper_log( - f"[pipeline async {req_id}] exception" - + (f" job={job_id}" if job_id else "") - + f": {type(exc).__name__}: {exc}" - ) - - thread = threading.Thread( - target=_target, - name=f"pipeline-async-{req_id}", - daemon=True, - ) - thread.start() - - -def _is_load_url_pipeline(pipeline_text: str) -> bool: - return str(pipeline_text or "").lstrip().lower().startswith(".mpv -url") - - -def _run_op(op: str, data: Any) -> Dict[str, Any]: - """Run a helper-only operation. - - These are NOT cmdlets and are not available via CLI pipelines. They exist - solely so MPV Lua can query lightweight metadata (e.g., autocomplete lists) - without inventing user-facing commands. - """ - op_name = str(op or "").strip().lower() - - if op_name in {"run-background", - "run_background", - "pipeline-background", - "pipeline_background"}: - pipeline_text = "" - seeds = None - json_output = False - requested_job_id = "" - if isinstance(data, dict): - pipeline_text = str(data.get("pipeline") or "").strip() - seeds = data.get("seeds") - json_output = bool(data.get("json") or data.get("output_json")) - requested_job_id = str(data.get("job_id") or "").strip() - - if not pipeline_text: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing pipeline", - "table": None, - } - - token = requested_job_id or f"job-{int(time.time() * 1000)}-{threading.get_ident()}" - job_id = hashlib.sha1( - f"{token}|{pipeline_text}|{time.time()}".encode("utf-8", "ignore") - ).hexdigest()[:16] - - _update_async_pipeline_job( - job_id, - status="queued", - success=False, - pipeline=pipeline_text, - req_id=job_id, - finished_at=None, - error=None, - stdout="", - stderr="", - table=None, - data=None, - ) - _run_pipeline_background( - pipeline_text, - seeds=seeds, - req_id=job_id, - job_id=job_id, - json_output=json_output, - ) - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "job_id": job_id, - "status": "queued", - } - - if op_name in {"job-status", - "job_status", - "pipeline-job-status", - "pipeline_job_status"}: - job_id = "" - if isinstance(data, dict): - job_id = str(data.get("job_id") or "").strip() - if not job_id: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing job_id", - "table": None, - } - - job = _get_async_pipeline_job(job_id) - if not job: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"Unknown job_id: {job_id}", - "table": None, - } - - payload = dict(job) - return { - "success": True, - "stdout": str(payload.get("stdout") or ""), - "stderr": str(payload.get("stderr") or ""), - "error": payload.get("error"), - "table": payload.get("table"), - "data": payload.get("data"), - "job": payload, - } - - if op_name in {"queue-repl-command", - "queue_repl_command", - "repl-command", - "repl_command"}: - command_text = "" - source = "mpv" - metadata = None - if isinstance(data, dict): - command_text = str(data.get("command") or data.get("pipeline") or "").strip() - source = str(data.get("source") or "mpv").strip() or "mpv" - metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else None - - if not command_text: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing command", - "table": None, - } - - queue_path = enqueue_repl_command( - _repo_root(), - command_text, - source=source, - metadata=metadata, - ) - _append_helper_log( - f"[repl-queue] queued source={source} path={queue_path.name} cmd={command_text}" - ) - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "path": str(queue_path), - "queued": True, - } - - if op_name in {"run-detached", - "run_detached", - "pipeline-detached", - "pipeline_detached"}: - pipeline_text = "" - seeds = None - if isinstance(data, dict): - pipeline_text = str(data.get("pipeline") or "").strip() - seeds = data.get("seeds") - if not pipeline_text: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing pipeline", - "table": None, - } - - py = sys.executable or "python" - if platform.system() == "Windows": - try: - exe = str(py or "").strip() - except Exception: - exe = "" - low = exe.lower() - if low.endswith("python.exe"): - try: - candidate = exe[:-10] + "pythonw.exe" - if os.path.exists(candidate): - py = candidate - except Exception: - pass - - cmd = [ - py, - str((_repo_root() / "CLI.py").resolve()), - "pipeline", - "--pipeline", - pipeline_text, - ] - if seeds is not None: - try: - cmd.extend(["--seeds-json", json.dumps(seeds, ensure_ascii=False)]) - except Exception: - # Best-effort; seeds are optional. - pass - - popen_kwargs: Dict[str, - Any] = { - "stdin": subprocess.DEVNULL, - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - "cwd": str(_repo_root()), - } - if platform.system() == "Windows": - flags = 0 - try: - flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008)) - except Exception: - flags |= 0x00000008 - try: - flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) - except Exception: - flags |= 0x08000000 - popen_kwargs["creationflags"] = int(flags) - try: - si = subprocess.STARTUPINFO() - si.dwFlags |= int( - getattr(subprocess, - "STARTF_USESHOWWINDOW", - 0x00000001) - ) - si.wShowWindow = subprocess.SW_HIDE - popen_kwargs["startupinfo"] = si - except Exception: - pass - else: - popen_kwargs["start_new_session"] = True - - try: - proc = subprocess.Popen(cmd, **popen_kwargs) - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": - f"Failed to spawn detached pipeline: {type(exc).__name__}: {exc}", - "table": None, - } - - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "pid": int(getattr(proc, - "pid", - 0) or 0), - } - - # Provide store backend choices using the dynamic registered store registry only. - if op_name in {"store-choices", - "store_choices", - "get-store-choices", - "get_store_choices"}: - cached_choices = _get_cached_store_choices() - refresh = False - if isinstance(data, dict): - refresh = bool(data.get("refresh") or data.get("reload")) - - if cached_choices and not refresh: - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "choices": cached_choices, - } - - try: - choices = _load_store_choices_from_config(force_reload=refresh) - - if not choices and cached_choices: - choices = cached_choices - - if choices: - choices = _set_cached_store_choices(choices) - - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "choices": choices, - } - except Exception as exc: - if cached_choices: - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "choices": cached_choices, - } - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"store-choices failed: {type(exc).__name__}: {exc}", - "table": None, - "choices": [], - } - - if op_name in {"url-exists", - "url_exists", - "find-url", - "find_url"}: - try: - from Store import Store # noqa: WPS433 - - cfg = load_config() or {} - storage = Store(config=cfg, suppress_debug=True) - - raw_needles: list[str] = [] - if isinstance(data, dict): - maybe_needles = data.get("needles") - if isinstance(maybe_needles, (list, tuple, set)): - for item in maybe_needles: - text = str(item or "").strip() - if text and text not in raw_needles: - raw_needles.append(text) - elif isinstance(maybe_needles, str): - text = maybe_needles.strip() - if text: - raw_needles.append(text) - - if not raw_needles: - text = str(data.get("url") or "").strip() - if text: - raw_needles.append(text) - - if not raw_needles: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing url", - "table": None, - "data": [], - } - - matches: list[dict[str, Any]] = [] - seen_keys: set[str] = set() - - for backend_name in storage.list_backends() or []: - try: - backend = storage[backend_name] - except Exception: - continue - - search_fn = getattr(backend, "search", None) - if not callable(search_fn): - continue - - for needle in raw_needles: - query = f"url:{needle}" - try: - results = backend.search( - query, - limit=1, - minimal=True, - url_only=True, - ) or [] - except Exception: - continue - - for item in results: - if hasattr(item, "to_dict") and callable(getattr(item, "to_dict")): - try: - item = item.to_dict() - except Exception: - item = {"title": str(item)} - elif not isinstance(item, dict): - item = {"title": str(item)} - - payload = dict(item) - payload.setdefault("store", str(backend_name)) - payload.setdefault("needle", str(needle)) - - key = str(payload.get("hash") or payload.get("url") or payload.get("title") or needle).strip().lower() - if key in seen_keys: - continue - seen_keys.add(key) - matches.append(payload) - - if matches: - break - - if matches: - break - - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": None, - "data": matches, - } - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"url-exists failed: {type(exc).__name__}: {exc}", - "table": None, - "data": [], - } - - # Provide yt-dlp format list for a URL (for MPV "Change format" menu). - # Returns a ResultTable-like payload so the Lua UI can render without running cmdlets. - if op_name in {"ytdlp-formats", - "ytdlp_formats", - "ytdl-formats", - "ytdl_formats"}: - try: - url = None - if isinstance(data, dict): - url = data.get("url") - url = str(url or "").strip() - if not url: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "Missing url", - "table": None, - } - - cfg = load_config() or {} - plugin = get_plugin("ytdlp", cfg) - if plugin is None or not hasattr(plugin, "list_url_formats"): - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "yt-dlp plugin unavailable", - "table": None, - } - - try: - formats = plugin.list_url_formats( - url, - no_playlist=True, - timeout_seconds=25, - ) - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"yt-dlp plugin probe failed: {type(exc).__name__}: {exc}", - "table": None, - } - - def _format_bytes(n: Any) -> str: - """Format bytes using centralized utility.""" - return format_bytes(n) - - if formats is None: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": "yt-dlp format probe failed or timed out", - "table": None, - } - - if not formats: - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": { - "title": "Formats", - "rows": [] - }, - } - - try: - formats = plugin.filter_picker_formats(formats) - except Exception: - pass - - # Debug: dump a short summary of the format list to the helper log. - try: - count = len(formats) - _append_helper_log( - f"[ytdlp-formats] extracted formats count={count} url={url}" - ) - - limit = 60 - for i, f in enumerate(formats[:limit], start=1): - if not isinstance(f, dict): - continue - fid = str(f.get("format_id") or "") - ext = str(f.get("ext") or "") - note = f.get("format_note") or f.get("format") or "" - vcodec = str(f.get("vcodec") or "") - acodec = str(f.get("acodec") or "") - size = f.get("filesize") or f.get("filesize_approx") - res = str(f.get("resolution") or "") - if not res: - try: - w = f.get("width") - h = f.get("height") - if w and h: - res = f"{int(w)}x{int(h)}" - elif h: - res = f"{int(h)}p" - except Exception: - res = "" - _append_helper_log( - f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}" - ) - if count > limit: - _append_helper_log( - f"[ytdlp-formats] (truncated; total={count})" - ) - except Exception: - pass - - rows = [] - for fmt in formats: - if not isinstance(fmt, dict): - continue - format_id = str(fmt.get("format_id") or "").strip() - if not format_id: - continue - display_id = get_display_format_id(fmt) or format_id - - # Prefer human-ish resolution. - resolution = str(fmt.get("resolution") or "").strip() - if not resolution: - w = fmt.get("width") - h = fmt.get("height") - try: - if w and h: - resolution = f"{int(w)}x{int(h)}" - elif h: - resolution = f"{int(h)}p" - except Exception: - resolution = "" - - ext = str(fmt.get("ext") or "").strip() - size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx")) - - selection_id = get_selection_format_id(fmt, video_audio_suffix="ba") or format_id - - # Build selection args compatible with MPV Lua picker. - # Use -format instead of -query so Lua can extract the ID easily. - selection_args = ["-format", selection_id] - - rows.append( - { - "columns": [ - { - "name": "ID", - "value": display_id - }, - { - "name": "Resolution", - "value": resolution or "" - }, - { - "name": "Ext", - "value": ext or "" - }, - { - "name": "Size", - "value": size or "" - }, - ], - "selection_args": - selection_args, - } - ) - - return { - "success": True, - "stdout": "", - "stderr": "", - "error": None, - "table": { - "title": "Formats", - "rows": rows - }, - } - except Exception as exc: - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"{type(exc).__name__}: {exc}", - "table": None, - } - - return { - "success": False, - "stdout": "", - "stderr": "", - "error": f"Unknown op: {op_name}", - "table": None, - } - - -def _append_helper_log(text: str) -> None: - """Log helper diagnostics to file, database, and the mpv console emitter.""" - payload = (text or "").rstrip() - if not payload: - return - - _HELPER_LOG_BACKLOG.append(payload) - if len(_HELPER_LOG_BACKLOG) > _HELPER_LOG_BACKLOG_LIMIT: - del _HELPER_LOG_BACKLOG[:-_HELPER_LOG_BACKLOG_LIMIT] - - try: - with open(_helper_log_path(), "a", encoding="utf-8", errors="replace") as fh: - fh.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {payload}\n") - except Exception: - pass - - try: - # Try database logging first (best practice: unified logging) - from SYS.database import log_to_db - log_to_db("INFO", "mpv", payload) - except Exception: - # Fallback to stderr if database unavailable - import sys - print(f"[mpv-helper] {payload}", file=sys.stderr) - - emitter = _HELPER_MPV_LOG_EMITTER - if emitter is not None: - try: - emitter(payload) - except Exception: - pass - - -def _helper_log_path() -> str: - try: - log_dir = _repo_root() / "Log" - log_dir.mkdir(parents=True, exist_ok=True) - return str((log_dir / "medeia-mpv-helper.log").resolve()) - except Exception: - tmp = tempfile.gettempdir() - return str((Path(tmp) / "medeia-mpv-helper.log").resolve()) - - -def _get_ipc_lock_path(ipc_path: str) -> Path: - """Return the lock file path for a given IPC path.""" - safe = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(ipc_path or "")) - if not safe: - safe = "mpv" - lock_dir = Path(tempfile.gettempdir()) / "medeia-mpv-helper" - lock_dir.mkdir(parents=True, exist_ok=True) - return lock_dir / f"medeia-mpv-helper-{safe}.lock" - - -def _get_ipc_lock_meta_path(ipc_path: str) -> Path: - lock_path = _get_ipc_lock_path(ipc_path) - return lock_path.with_suffix(lock_path.suffix + ".json") - - -def _read_lock_file_pid(ipc_path: str) -> Optional[int]: - """Return the PID recorded in the lock file by the current holder, or None. - - The lock file can be opened for reading even while another process holds the - byte-range lock (msvcrt.locking is advisory, not a file-open exclusive lock). - This lets a challenger identify the exact holder PID and kill only that process, - avoiding the race where concurrent sibling helpers all kill each other. - """ - try: - lock_path = _get_ipc_lock_meta_path(ipc_path) - with open(str(lock_path), "r", encoding="utf-8", errors="replace") as fh: - content = fh.read().strip() - if not content: - return None - data = json.loads(content) - pid = int(data.get("pid") or 0) - return pid if pid > 0 else None - except Exception: - return None - - -def _write_lock_file_metadata(ipc_path: str) -> None: - meta_path = _get_ipc_lock_meta_path(ipc_path) - meta_path.write_text( - json.dumps( - { - "pid": os.getpid(), - "version": MEDEIA_MPV_HELPER_VERSION, - "ipc": str(ipc_path), - "started_at": int(time.time()), - }, - ensure_ascii=False, - ), - encoding="utf-8", - errors="replace", - ) - - -def _release_ipc_lock(fh: Any, ipc_path: Optional[str] = None) -> None: - if fh is None: - if ipc_path: - try: - _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) - except Exception: - pass - return - try: - if os.name == "nt": - import msvcrt # type: ignore - - try: - fh.seek(0) - except Exception: - pass - msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) - else: - import fcntl # type: ignore - - fcntl.flock(fh.fileno(), fcntl.LOCK_UN) - except Exception: - pass - try: - fh.close() - except Exception: - pass - if ipc_path: - try: - _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) - except Exception: - pass - - -def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: - """Best-effort singleton lock per IPC path. - - Multiple helpers subscribing to mpv log-message events causes duplicated - log output. Use a tiny file lock to ensure one helper per mpv instance. - """ - try: - lock_path = _get_ipc_lock_path(ipc_path) - fh = open(lock_path, "a+", encoding="utf-8", errors="replace") - - # On Windows, locking a zero-length file can fail even when no process - # actually owns the lock anymore. Prime the file with a single byte so - # stale empty lock files do not wedge future helper startups. - try: - fh.seek(0, os.SEEK_END) - if fh.tell() < 1: - fh.write("\n") - fh.flush() - except Exception: - pass - - if os.name == "nt": - try: - import msvcrt # type: ignore - - fh.seek(0) - msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) - except Exception: - try: - fh.close() - except Exception: - pass - return None - else: - try: - import fcntl # type: ignore - - fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except Exception: - try: - fh.close() - except Exception: - pass - return None - - try: - _write_lock_file_metadata(ipc_path) - except Exception: - pass - - return fh - except Exception: - return None - - -def _parse_request(data: Any) -> Optional[Dict[str, Any]]: - if data is None: - return None - if isinstance(data, str): - text = data.strip() - if not text: - return None - try: - obj = json.loads(text) - except Exception: - return None - return obj if isinstance(obj, dict) else None - if isinstance(data, dict): - return data - return None - - -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. - - Windows named-pipe event delivery can stall even while direct get/set - property commands still work. Polling the request property provides a more - reliable fallback transport for helper ops and pipeline dispatch. - """ - - def _poll_loop() -> None: - poll_client = MPVIPCClient(socket_path=ipc_path, timeout=0.75, silent=True) - while not stop_event.is_set(): - try: - 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: - pass - 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: - pass - stop_event.wait(0.15) - - try: - poll_client.disconnect() - except Exception: - pass - - thread = threading.Thread( - target=_poll_loop, - name="mpv-helper-request-poll", - daemon=True, - ) - thread.start() - return thread - - -def main(argv: Optional[list[str]] = None) -> int: - parser = argparse.ArgumentParser(prog="mpv-pipeline-helper") - parser.add_argument("--ipc", required=True, help="mpv --input-ipc-server path") - parser.add_argument("--timeout", type=float, default=15.0) - args = parser.parse_args(argv) - - # Load config once and configure logging similar to CLI.pipeline. - try: - cfg = load_config() or {} - except Exception: - cfg = {} - - try: - debug_enabled = bool(isinstance(cfg, dict) and cfg.get("debug", False)) - set_debug(debug_enabled) - - if debug_enabled: - logging.basicConfig( - level=logging.DEBUG, - format="[%(name)s] %(levelname)s: %(message)s", - stream=sys.stderr, - ) - for noisy in ("httpx", - "httpcore", - "httpcore.http11", - "httpcore.connection"): - try: - logging.getLogger(noisy).setLevel(logging.WARNING) - except Exception: - pass - except Exception: - pass - - # Ensure all in-process cmdlets that talk to MPV pick up the exact IPC server - # path used by this helper (which comes from the running MPV instance). - os.environ["MEDEIA_MPV_IPC"] = str(args.ipc) - - # Generous deadline: the kill + OS-lock-release cycle can take several seconds, - # especially when a stale helper is running as a different process image. - lock_wait_deadline = time.time() + 12.0 - lock_wait_logged = False - _lock_fh = None - _kill_attempted = False # kill at most once; re-killing on every loop causes sibling helpers to kill each other - - while _lock_fh is None: - # Try to acquire the lock first — avoids unnecessary process enumeration - # when there is no contention (normal cold-start path). - _lock_fh = _acquire_ipc_lock(str(args.ipc)) - if _lock_fh is not None: - break - - if not lock_wait_logged: - _append_helper_log( - f"[helper] waiting for helper lock release ipc={args.ipc}" - ) - lock_wait_logged = True - - if time.time() >= lock_wait_deadline: - _append_helper_log( - f"[helper] another instance still holds lock for ipc={args.ipc}; exiting after wait" - ) - return 0 - - # Kill the lock holder at most once. Repeatedly scanning for all matching - # processes on every iteration caused concurrent sibling helpers (spawned by - # the Lua 3-second timeout cycle) to kill each other before any could start. - if platform.system() == "Windows" and not _kill_attempted: - _kill_attempted = True - try: - # Prefer targeted kill via PID recorded in the lock file. - # msvcrt byte-range locking does not prevent reading the file from - # another process, so we can always identify the exact holder PID. - holder_pid = _read_lock_file_pid(str(args.ipc)) - if holder_pid and holder_pid != os.getpid(): - _append_helper_log( - f"[helper] killing lock holder pid={holder_pid} ipc={args.ipc}" - ) - _windows_kill_pids([holder_pid]) - else: - # Fallback: old helpers (pre-PID-in-lock-file) left no PID. - # Scan once by command-line pattern. - sibling_pids = [ - p for p in _windows_list_pipeline_helper_pids(str(args.ipc)) - if p and p != os.getpid() - ] - if sibling_pids: - _append_helper_log( - f"[helper] killing old-style sibling pids={sibling_pids} ipc={args.ipc}" - ) - _windows_kill_pids(sibling_pids) - else: - _append_helper_log( - f"[helper] no identifiable lock holder for ipc={args.ipc}; waiting" - ) - except Exception as exc: - _append_helper_log( - f"[helper] kill failed: {type(exc).__name__}: {exc}" - ) - - time.sleep(0.5) - - try: - _append_helper_log( - f"[helper] version={MEDEIA_MPV_HELPER_VERSION} started ipc={args.ipc}" - ) - try: - _write_lock_file_metadata(str(args.ipc)) - except Exception: - pass - try: - _append_helper_log( - f"[helper] file={Path(__file__).resolve()} cwd={Path.cwd().resolve()}" - ) - except Exception: - pass - try: - runtime_root = _runtime_config_root() - _append_helper_log( - f"[helper] config_root={runtime_root} exists={bool((runtime_root / 'config.conf').exists())}" - ) - except Exception: - pass - debug(f"[mpv-helper] logging to: {_helper_log_path()}") - except Exception: - pass - - # Route SYS.logger output into the helper log file so diagnostics are not - # lost in mpv's console/terminal output. - try: - - class _HelperLogStream: - - def __init__(self) -> None: - self._pending = "" - - def write(self, s: str) -> int: - if not s: - return 0 - text = self._pending + str(s) - lines = text.splitlines(keepends=True) - if lines and not lines[-1].endswith(("\n", "\r")): - self._pending = lines[-1] - lines = lines[:-1] - else: - self._pending = "" - for line in lines: - payload = line.rstrip("\r\n") - if payload: - _append_helper_log("[py] " + payload) - return len(s) - - def flush(self) -> None: - if self._pending: - _append_helper_log("[py] " + self._pending.rstrip("\r\n")) - self._pending = "" - - set_thread_stream(_HelperLogStream()) - except Exception: - pass - - # Prefer a stable repo-local log folder for discoverability. - error_log_dir = _repo_root() / "Log" - try: - error_log_dir.mkdir(parents=True, exist_ok=True) - except Exception: - error_log_dir = Path(tempfile.gettempdir()) - last_error_log = error_log_dir / "medeia-mpv-pipeline-last-error.log" - seen_request_ids: Dict[str, float] = {} - seen_request_ids_lock = threading.Lock() - 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) - except Exception: - pass - - payload = (text or "").strip() - if not payload: - return None - - stamped = error_log_dir / f"medeia-mpv-pipeline-error-{req_id}.log" - try: - stamped.write_text(payload, encoding="utf-8", errors="replace") - except Exception: - stamped = None - - try: - last_error_log.write_text(payload, encoding="utf-8", errors="replace") - except Exception: - pass - - return str(stamped) if stamped else str(last_error_log) - - def _mark_request_seen(req_id: str) -> bool: - if not req_id: - return False - now = time.time() - cutoff = now - seen_request_ttl_seconds - with seen_request_ids_lock: - expired = [key for key, ts in seen_request_ids.items() if ts < cutoff] - for key in expired: - seen_request_ids.pop(key, None) - if req_id in seen_request_ids: - return False - seen_request_ids[req_id] = now - return True - - def _publish_response(resp: Dict[str, Any]) -> None: - req_id = str(resp.get("id") or "") - ok = _send_helper_command( - [ - "set_property_string", - RESPONSE_PROP, - json.dumps(resp, ensure_ascii=False), - ], - f"response:{req_id or 'unknown'}", - ) - if ok: - _append_helper_log( - f"[response {req_id or '?'}] published success={bool(resp.get('success'))}" - ) - else: - _append_helper_log( - f"[response {req_id or '?'}] publish failed success={bool(resp.get('success'))}" - ) - - def _process_request(raw: Any, source: str) -> bool: - req = _parse_request(raw) - if not req: - try: - if isinstance(raw, str) and raw.strip(): - snippet = raw.strip().replace("\r", "").replace("\n", " ") - if len(snippet) > 220: - snippet = snippet[:220] + "…" - _append_helper_log( - f"[request-raw {source}] could not parse request json: {snippet}" - ) - except Exception: - pass - return False - - req_id = str(req.get("id") or "") - op = str(req.get("op") or "").strip() - data = req.get("data") - pipeline_text = str(req.get("pipeline") or "").strip() - seeds = req.get("seeds") - json_output = bool(req.get("json") or req.get("output_json")) - - if not req_id: - return False - - if not _mark_request_seen(req_id): - return False - - try: - label = pipeline_text if pipeline_text else (op and ("op=" + op) or "(empty)") - _append_helper_log(f"\n[request {req_id} via {source}] {label}") - except Exception: - pass - - with request_processing_lock: - async_dispatch = False - try: - if op: - run = _run_op(op, data) - else: - if not pipeline_text: - return False - if _is_load_url_pipeline(pipeline_text): - async_dispatch = True - run = { - "success": True, - "stdout": "", - "stderr": "", - "error": "", - "table": None, - } - _run_pipeline_background( - pipeline_text, - seeds=seeds, - req_id=req_id, - ) - else: - run = _run_pipeline( - pipeline_text, - seeds=seeds, - json_output=json_output, - ) - - resp = { - "id": req_id, - "success": bool(run.get("success")), - "stdout": run.get("stdout", ""), - "stderr": run.get("stderr", ""), - "error": run.get("error"), - "table": run.get("table"), - "data": run.get("data"), - } - if "choices" in run: - resp["choices"] = run.get("choices") - if "job_id" in run: - resp["job_id"] = run.get("job_id") - if "job" in run: - resp["job"] = run.get("job") - if "status" in run: - resp["status"] = run.get("status") - if "pid" in run: - resp["pid"] = run.get("pid") - if async_dispatch: - resp["info"] = "queued asynchronously" - except Exception as exc: - resp = { - "id": req_id, - "success": False, - "stdout": "", - "stderr": "", - "error": f"{type(exc).__name__}: {exc}", - "table": None, - } - - try: - if op: - extra = "" - if isinstance(resp.get("choices"), list): - extra = f" choices={len(resp.get('choices') or [])}" - _append_helper_log( - f"[request {req_id}] op-finished success={bool(resp.get('success'))}{extra}" - ) - except Exception: - pass - - try: - if resp.get("stdout"): - _append_helper_log("[stdout]\n" + str(resp.get("stdout"))) - if resp.get("stderr"): - _append_helper_log("[stderr]\n" + str(resp.get("stderr"))) - if resp.get("error"): - _append_helper_log("[error]\n" + str(resp.get("error"))) - except Exception: - pass - - if not resp.get("success"): - details = "" - if resp.get("error"): - details += str(resp.get("error")) - if resp.get("stderr"): - details = (details + "\n" if details else "") + str(resp.get("stderr")) - log_path = _write_error_log(details, req_id=req_id) - if log_path: - resp["log_path"] = log_path - - try: - _publish_response(resp) - except Exception: - pass - - if resp.get("success") and isinstance(resp.get("choices"), list): - try: - _publish_store_choices_cached_property(resp.get("choices")) - except Exception: - pass - - return True - - # Connect to mpv's JSON IPC. On Windows, the pipe can exist but reject opens - # briefly during startup; also mpv may create the IPC server slightly after - # the Lua script launches us. Retry until timeout. - connect_deadline = time.time() + max(0.5, float(args.timeout)) - last_connect_error: Optional[str] = None - - client = MPVIPCClient(socket_path=args.ipc, timeout=0.5, silent=True) - while True: - try: - if client.connect(): - _mark_ipc_alive("startup-connect") - break - except Exception as exc: - last_connect_error = f"{type(exc).__name__}: {exc}" - - if time.time() > connect_deadline: - _append_helper_log( - f"[helper] failed to connect ipc={args.ipc} error={last_connect_error or 'timeout'}" - ) - return 2 - - # Keep trying. - time.sleep(0.10) - - use_shared_ipc_client = platform.system() == "Windows" - command_client = None if use_shared_ipc_client else MPVIPCClient(socket_path=str(args.ipc), timeout=0.75, silent=True) - - def _send_helper_command(command: Any, label: str = "") -> bool: - with command_client_lock: - target_client = client if use_shared_ipc_client else command_client - if target_client is None: - return False - try: - if target_client.sock is None: - if use_shared_ipc_client: - _note_ipc_unavailable(f"helper-command-connect:{label or '?'}") - return False - if not target_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 = target_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: - target_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: - target_client.disconnect() - except Exception: - pass - return False - - def _emit_helper_log_to_mpv(payload: str) -> None: - safe = str(payload or "").replace("\r", " ").replace("\n", " ").strip() - if not safe: - return - if len(safe) > 900: - safe = safe[:900] + "..." - try: - client.send_command_no_wait(["print-text", f"medeia-helper: {safe}"]) - except Exception: - return - - global _HELPER_MPV_LOG_EMITTER - _HELPER_MPV_LOG_EMITTER = _emit_helper_log_to_mpv - for backlog_line in list(_HELPER_LOG_BACKLOG): - try: - _emit_helper_log_to_mpv(backlog_line) - except Exception: - break - - # Mark ready ASAP and keep it fresh. - # Use a unix timestamp so the Lua side can treat it as a heartbeat. - last_ready_ts: float = 0.0 - - def _touch_ready() -> None: - nonlocal last_ready_ts - now = time.time() - # Throttle updates to reduce IPC chatter. - if (now - last_ready_ts) < 0.75: - return - try: - _send_helper_command( - ["set_property_string", READY_PROP, str(int(now))], - "ready-heartbeat", - ) - last_ready_ts = now - except Exception: - return - - def _publish_store_choices_cached_property(choices: Any) -> None: - payload = _store_choices_payload(choices) - if not payload: - return - _send_helper_command( - [ - "set_property_string", - "user-data/medeia-store-choices-cached", - payload, - ], - "store-choices-cache", - ) - - def _publish_helper_version() -> None: - if _send_helper_command( - ["set_property_string", VERSION_PROP, MEDEIA_MPV_HELPER_VERSION], - "helper-version", - ): - _append_helper_log( - f"[helper] published helper version {MEDEIA_MPV_HELPER_VERSION}" - ) - - # Mirror mpv's own log messages into our helper log file so debugging does - # not depend on the mpv on-screen console or mpv's log-file. - try: - # IMPORTANT: mpv debug logs can be extremely chatty (especially ytdl_hook) - # and can starve request handling. Default to warn unless explicitly overridden. - level = os.environ.get("MEDEIA_MPV_HELPER_MPVLOG", "").strip() or "warn" - client.send_command_no_wait(["request_log_messages", level]) - _append_helper_log(f"[helper] requested mpv log messages level={level}") - except Exception: - pass - - # De-dup/throttle mpv log-message lines (mpv and yt-dlp can be very chatty). - last_mpv_line: Optional[str] = None - last_mpv_count: int = 0 - last_mpv_ts: float = 0.0 - - def _flush_mpv_repeat() -> None: - nonlocal last_mpv_line, last_mpv_count - if last_mpv_line and last_mpv_count > 1: - _append_helper_log(f"[mpv] (previous line repeated {last_mpv_count}x)") - last_mpv_line = None - last_mpv_count = 0 - - # Observe request property changes. - try: - client.send_command_no_wait( - ["observe_property", - OBS_ID_REQUEST, - REQUEST_PROP, - "string"] - ) - except Exception: - return 3 - - # Mark ready only after the observer is installed to avoid races where Lua - # sends a request before we can receive property-change notifications. - try: - _touch_ready() - _publish_helper_version() - _append_helper_log(f"[helper] ready heartbeat armed prop={READY_PROP}") - except Exception: - pass - - if use_shared_ipc_client: - _append_helper_log( - "[helper] Windows single-client IPC mode enabled; auxiliary heartbeat/poll disabled" - ) - else: - _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). - try: - startup_choices_payload = _run_op("store-choices", None) - startup_choices = ( - startup_choices_payload.get("choices") - if isinstance(startup_choices_payload, - dict) else None - ) - if isinstance(startup_choices, list): - startup_choices = _set_cached_store_choices(startup_choices) - preview = ", ".join(str(x) for x in startup_choices[:50]) - _append_helper_log( - f"[helper] startup store-choices count={len(startup_choices)} items={preview}" - ) - - # Publish to a cached property for Lua to read without IPC request. - try: - _publish_store_choices_cached_property(startup_choices) - _append_helper_log( - "[helper] published store-choices to user-data/medeia-store-choices-cached" - ) - except Exception as exc: - _append_helper_log( - f"[helper] failed to publish store-choices: {type(exc).__name__}: {exc}" - ) - else: - _append_helper_log("[helper] startup store-choices unavailable") - except Exception as exc: - _append_helper_log( - f"[helper] startup store-choices failed: {type(exc).__name__}: {exc}" - ) - - # Also publish config temp directory if available - try: - cfg = load_config() - temp_dir = cfg.get("temp", "").strip() or os.getenv("TEMP") or "/tmp" - if temp_dir: - _send_helper_command( - ["set_property_string", - "user-data/medeia-config-temp", - temp_dir], - "config-temp", - ) - _append_helper_log( - f"[helper] published config temp to user-data/medeia-config-temp={temp_dir}" - ) - except Exception as exc: - _append_helper_log( - f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}" - ) - - # Publish yt-dlp supported domains for Lua menu filtering - try: - plugin_class = get_plugin_class("ytdlp") - domains = [] - if plugin_class is not None: - domains = sorted( - { - str(value).strip().lower() - for value in plugin_class.url_patterns() - if isinstance(value, str) - and str(value).strip() - and "://" not in str(value) - and not str(value).strip().endswith(":") - } - ) - if domains: - # We join them into a space-separated string for Lua to parse easily - domains_str = " ".join(domains) - _send_helper_command( - [ - "set_property_string", - "user-data/medeia-ytdlp-domains-cached", - domains_str - ], - "ytdlp-domains", - ) - _append_helper_log( - f"[helper] published {len(domains)} ytdlp domains for Lua menu filtering" - ) - except Exception as exc: - _append_helper_log( - f"[helper] failed to publish ytdlp domains: {type(exc).__name__}: {exc}" - ) - - try: - _append_helper_log(f"[helper] connected to ipc={args.ipc}") - except Exception: - pass - - 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 - - _mark_ipc_alive("main-read") - - if msg.get("event") == "__eof__": - _request_shutdown("mpv closed ipc stream") - break - - 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 not text: - continue - - # 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() - - 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 - _append_helper_log(line) - except Exception: - pass - continue - - 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 - if command_client is not None: - try: - command_client.disconnect() - except Exception: - pass - try: - client.disconnect() - except Exception: - pass - try: - _release_ipc_lock(_lock_fh, str(args.ipc)) - except Exception: - pass - - return 0 +from plugins.mpv.pipeline_helper import * +from plugins.mpv.pipeline_helper import main as _main if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(_main()) \ No newline at end of file diff --git a/Provider/__init__.py b/Provider/__init__.py deleted file mode 100644 index 4ea97b6..0000000 --- a/Provider/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Legacy compatibility package. - -Bundled runtime plugins now live under ``plugins/``. This package remains only -for helper modules and backwards-compatible imports that have not been removed -yet. -""" diff --git a/Provider/example_provider.py b/Provider/example_provider.py deleted file mode 100644 index 46d5543..0000000 --- a/Provider/example_provider.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Legacy compatibility shim for the strict adapter example module. - -The active implementation now lives in ``plugins.example_provider`` so the -plugin namespace owns the example adapter module. Keep this file only to avoid -breaking old imports while the legacy ``Provider`` package is phased out. -""" - -from plugins.example_provider import * # noqa: F401,F403 diff --git a/Provider/metadata_provider.py b/Provider/metadata_provider.py deleted file mode 100644 index a9278b6..0000000 --- a/Provider/metadata_provider.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Legacy compatibility shim for metadata helpers. - -The active implementation now lives in ``plugins.metadata_provider`` so the -plugin namespace owns runtime metadata scraping. Keep this file only to avoid -breaking old imports while the legacy ``Provider`` package is phased out. -""" - -from plugins.metadata_provider import * # noqa: F401,F403 diff --git a/Provider/tidal_manifest.py b/Provider/tidal_manifest.py deleted file mode 100644 index 0ae9148..0000000 --- a/Provider/tidal_manifest.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Legacy compatibility shim for Tidal manifest helpers. - -The active implementation now lives in ``plugins.tidal_manifest`` so the -plugin namespace owns the manifest helper module. Keep this file only to avoid -breaking old imports while the legacy ``Provider`` package is phased out. -""" - -from plugins.tidal_manifest import * # noqa: F401,F403 diff --git a/ProviderCore/base.py b/ProviderCore/base.py index cc9e05f..03e6795 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -30,6 +30,7 @@ class SearchResult: def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for pipeline processing.""" + full_metadata = self.full_metadata if isinstance(self.full_metadata, dict) else {} out = { "table": self.table, "title": self.title, @@ -40,15 +41,29 @@ class SearchResult: "size_bytes": self.size_bytes, "tag": list(self.tag), "columns": list(self.columns), - "full_metadata": self.full_metadata, + "full_metadata": full_metadata, } - try: - url_value = getattr(self, "url", None) - if url_value is not None: - out["url"] = url_value - except Exception: - pass + for key in ( + "url", + "hash", + "hash_hex", + "store", + "name", + "mime", + "file_id", + "ext", + "size", + ): + value = None + try: + value = getattr(self, key, None) + except Exception: + value = None + if value is None and key in full_metadata: + value = full_metadata.get(key) + if value is not None: + out[key] = value try: selection_args = getattr(self, "selection_args", None) @@ -195,6 +210,30 @@ class Provider(ABC): """ return "search-file", list(args_list) + def resolve_pipe_item_context( + self, + item: Any, + *, + metadata: Optional[Dict[str, Any]] = None, + store: Optional[str] = None, + file_hash: Optional[str] = None, + targets: Optional[Sequence[str]] = None, + ) -> Optional[Tuple[Optional[str], Optional[str]]]: + """Optionally normalize store/hash context for pipe playback helpers.""" + _ = item, metadata, store, file_hash, targets + return None + + def infer_playlist_store( + self, + item: Any, + *, + target: str, + file_storage: Any = None, + ) -> Optional[str]: + """Optionally infer a friendly store label for an MPV playlist entry.""" + _ = item, target, file_storage + return None + @property def prefers_transfer_progress(self) -> bool: """True if this plugin prefers explicit transfer progress tracking (begin/finish) during download.""" diff --git a/ProviderCore/commands.py b/ProviderCore/commands.py new file mode 100644 index 0000000..009252b --- /dev/null +++ b/ProviderCore/commands.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from importlib import import_module +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, Sequence + + +CmdletFn = Callable[[Any, Sequence[str], Dict[str, Any]], int] + + +def iter_command_objects(module: Any) -> list[Any]: + objects: list[Any] = [] + + many = getattr(module, "COMMANDS", None) + if isinstance(many, (list, tuple)): + for item in many: + if item is not None: + objects.append(item) + + single = getattr(module, "COMMAND", None) + if single is not None: + objects.append(single) + + legacy = getattr(module, "CMDLET", None) + if legacy is not None: + objects.append(legacy) + + deduped: list[Any] = [] + seen: set[int] = set() + for item in objects: + marker = id(item) + if marker in seen: + continue + seen.add(marker) + deduped.append(item) + return deduped + + +def get_primary_command_object(module: Any) -> Any: + commands = iter_command_objects(module) + return commands[0] if commands else None + + +def _register_command_object(cmdlet_obj: Any, registry: Dict[str, CmdletFn]) -> None: + run_fn = getattr(cmdlet_obj, "exec", None) if hasattr(cmdlet_obj, "exec") else None + if not callable(run_fn): + return + + name = getattr(cmdlet_obj, "name", None) + if name: + registry[str(name).replace("_", "-").lower()] = run_fn + + aliases: list[str] = [] + if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"): + aliases.extend(getattr(cmdlet_obj, "alias") or []) + if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"): + aliases.extend(getattr(cmdlet_obj, "aliases") or []) + + for alias in aliases: + text = str(alias or "").strip() + if text: + registry[text.replace("_", "-").lower()] = run_fn + + +def iter_plugin_command_module_names() -> list[str]: + try: + repo_root = Path(__file__).resolve().parent.parent + except Exception: + return [] + + plugins_dir = repo_root / "plugins" + if not plugins_dir.is_dir(): + return [] + + module_names: list[str] = [] + for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()): + if not entry.is_dir() or entry.name.startswith("."): + continue + if not (entry / "__init__.py").is_file(): + continue + if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file(): + module_names.append(f"plugins.{entry.name}.commands") + return module_names + + +def register_plugin_commands(registry: Dict[str, CmdletFn]) -> None: + for module_name in iter_plugin_command_module_names(): + try: + module = import_module(module_name) + for cmdlet_obj in iter_command_objects(module): + _register_command_object(cmdlet_obj, registry) + except Exception as exc: + import sys + + print( + f"Error importing plugin command '{module_name}': {exc}", + file=sys.stderr, + ) + continue \ No newline at end of file diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index eda0acc..3c2291d 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -505,6 +505,18 @@ def _supports_capability(provider: Provider, capability: str) -> bool: return _supports_search(provider) if capability_key in {"upload", "file", "file-provider"}: return _supports_upload(provider) + if capability_key in {"pipe-item-context", "pipe-context"}: + return _class_supports_method( + provider.__class__, + "resolve_pipe_item_context", + Provider.resolve_pipe_item_context, + ) + if capability_key in {"playlist-store", "playback-store"}: + return _class_supports_method( + provider.__class__, + "infer_playlist_store", + Provider.infer_playlist_store, + ) return False @@ -514,6 +526,18 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool: return bool(info.supports_search) if capability_key in {"upload", "file", "file-provider"}: return bool(info.supports_upload) + if capability_key in {"pipe-item-context", "pipe-context"}: + return _class_supports_method( + info.plugin_class, + "resolve_pipe_item_context", + Provider.resolve_pipe_item_context, + ) + if capability_key in {"playlist-store", "playback-store"}: + return _class_supports_method( + info.plugin_class, + "infer_playlist_store", + Provider.infer_playlist_store, + ) return False diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index 79ce7eb..2135a61 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -5,6 +5,7 @@ from importlib import import_module, reload as reload_module from types import ModuleType from typing import Any, Dict, List, Optional import logging +from ProviderCore.commands import get_primary_command_object from ProviderCore.registry import get_plugin logger = logging.getLogger(__name__) @@ -71,17 +72,22 @@ def _normalize_mod_name(mod_name: str) -> str: def import_cmd_module(mod_name: str, *, reload_loaded: bool = False): - """Import a cmdlet/native module from cmdnat or cmdlet packages.""" + """Import a cmdlet/command module from legacy or plugin-owned packages.""" normalized = _normalize_mod_name(mod_name) if not normalized: return None - for package in ("cmdnat", "cmdlet", None): + for qualified in ( + f"plugins.{normalized}.commands", + f"cmdnat.{normalized}", + f"cmdlet.{normalized}", + normalized, + ): try: # When attempting a bare import (package is None), prefer the repo-local # `MPV` package for the `mpv` module name so we don't accidentally # import the third-party `mpv` package (python-mpv) which can raise # OSError if system libmpv is missing. - if package is None and normalized == "mpv": + if qualified == normalized and normalized == "mpv": try: if reload_loaded and "MPV" in sys.modules: return reload_module(sys.modules["MPV"]) @@ -90,7 +96,6 @@ def import_cmd_module(mod_name: str, *, reload_loaded: bool = False): # Local MPV package not present; fall back to the normal bare import. pass - qualified = f"{package}.{normalized}" if package else normalized if reload_loaded and qualified in sys.modules: return reload_module(sys.modules[qualified]) return import_module(qualified) @@ -151,7 +156,7 @@ def get_cmdlet_metadata( ensure_registry_loaded() normalized = cmd_name.replace("-", "_") mod = import_cmd_module(normalized) - data = getattr(mod, "CMDLET", None) if mod else None + data = get_primary_command_object(mod) if mod else None if data is None: try: @@ -161,7 +166,7 @@ def get_cmdlet_metadata( owner_mod = getattr(reg_fn, "__module__", "") if owner_mod: owner = import_module(owner_mod) - data = getattr(owner, "CMDLET", None) + data = get_primary_command_object(owner) except Exception as exc: logger.exception("Registry fallback failed while resolving cmdlet %s: %s", cmd_name, exc) data = None @@ -371,7 +376,7 @@ def get_cmdlet_arg_choices( ids = [] if ids: - # Try to resolve names via Provider.matrix if config provides auth info + # Try to resolve names via the Matrix plugin if config provides auth info try: hs = matrix_conf.get("homeserver") token = matrix_conf.get("access_token") diff --git a/SYS/cmdlet_spec.py b/SYS/cmdlet_spec.py index 728eb3f..b136929 100644 --- a/SYS/cmdlet_spec.py +++ b/SYS/cmdlet_spec.py @@ -33,13 +33,17 @@ class CmdletArg: return value def to_flags(self) -> tuple[str, ...]: - flags = [f"--{self.name}", f"-{self.name}"] + normalized_name = str(self.name or "").lstrip("-") + if not normalized_name: + return tuple() + + flags = [f"--{normalized_name}", f"-{normalized_name}"] if self.alias: flags.append(f"-{self.alias}") if self.type == "flag": - flags.append(f"--no-{self.name}") - flags.append(f"-no{self.name}") + flags.append(f"--no-{normalized_name}") + flags.append(f"-no{normalized_name}") if self.alias: flags.append(f"-n{self.alias}") diff --git a/SYS/command_parsing.py b/SYS/command_parsing.py new file mode 100644 index 0000000..b1fa024 --- /dev/null +++ b/SYS/command_parsing.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from typing import Any, Iterable, List, Optional, Sequence + +VALUE_ARG_FLAGS = frozenset({"-value", "--value", "-set-value", "--set-value"}) + + +def extract_piped_value(result: Any) -> Optional[str]: + if isinstance(result, str): + return result.strip() if result.strip() else None + if isinstance(result, (int, float)): + return str(result) + if isinstance(result, dict): + value = result.get("value") + if value is not None: + return str(value).strip() + return None + + +def extract_arg_value( + args: Sequence[str], + *, + flags: Iterable[str], + allow_positional: bool = False, +) -> Optional[str]: + if not args: + return None + + tokens = [str(tok) for tok in args if tok is not None] + normalized_flags = { + str(flag).strip().lower() for flag in flags if str(flag).strip() + } + if not normalized_flags: + return None + + for idx, tok in enumerate(tokens): + text = tok.strip() + if not text: + continue + low = text.lower() + if low in normalized_flags and idx + 1 < len(tokens): + candidate = str(tokens[idx + 1]).strip() + if candidate: + return candidate + if "=" in low: + head, value = low.split("=", 1) + if head in normalized_flags and value: + return value.strip() + + if not allow_positional: + return None + + for tok in tokens: + text = str(tok).strip() + if text and not text.startswith("-"): + return text + return None + + +def extract_value_arg(args: Sequence[str]) -> Optional[str]: + return extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=True) + + +def has_flag(args: Sequence[str], flag: str) -> bool: + try: + want = str(flag or "").strip().lower() + if not want: + return False + return any(str(arg).strip().lower() == want for arg in (args or [])) + except Exception: + return False + + +def normalize_to_list(value: Any) -> List[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] diff --git a/SYS/pipeline.py b/SYS/pipeline.py index 7dff2cb..2d5e385 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -769,14 +769,6 @@ def set_last_result_table_overlay( state.display_items = items or [] state.display_subject = subject -def set_last_result_table_preserve_history( - result_table: Optional[Any], - items: Optional[List[Any]] = None, - subject: Optional[Any] = None -) -> None: - """Compatibility alias for set_last_result_table_overlay.""" - set_last_result_table_overlay(result_table, items=items, subject=subject) - def set_last_result_items_only(items: Optional[List[Any]]) -> None: """ Store items for @N selection WITHOUT affecting history or saved search data. diff --git a/SYS/plugin_config.py b/SYS/plugin_config.py index 4095f5d..6129fa0 100644 --- a/SYS/plugin_config.py +++ b/SYS/plugin_config.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, List, Optional from SYS.config import global_config from ProviderCore.registry import get_plugin_class, list_plugins -from Store.registry import _discover_store_classes, _required_keys_for +from Store.registry import _discover_store_classes, _required_keys_for, _resolve_store_class logger = logging.getLogger(__name__) @@ -54,8 +54,7 @@ def _call_schema(owner: Any, label: str) -> List[ConfigField]: def get_store_schema(store_type: str) -> List[ConfigField]: - classes = _discover_store_classes() - cls = classes.get(str(store_type or "").strip()) + cls = _resolve_store_class(str(store_type or "").strip()) if cls is None: return [] return _call_schema(cls, f"store '{store_type}'") @@ -115,8 +114,7 @@ def build_default_store_config(store_type: str, instance_name: str) -> Dict[str, config[key] = field.get("default", "") return config - classes = _discover_store_classes() - cls = classes.get(str(store_type or "").strip()) + cls = _resolve_store_class(str(store_type or "").strip()) if cls is None: return config for required_key in _required_keys_for(cls): @@ -174,8 +172,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]: if normalized_type.startswith("store-"): store_type = normalized_type.replace("store-", "", 1) - classes = _discover_store_classes() - cls = classes.get(store_type) + cls = _resolve_store_class(store_type) if cls is not None: for required_key in _required_keys_for(cls): _add_key(required_key) diff --git a/SYS/result_table.py b/SYS/result_table.py index 41f718d..fad1477 100644 --- a/SYS/result_table.py +++ b/SYS/result_table.py @@ -13,7 +13,7 @@ Features: from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Callable, Set +from typing import Any, Dict, List, Optional, Callable, Set, Tuple from pathlib import Path import json import re @@ -94,6 +94,42 @@ def _partition_detail_tags(tags: Any) -> tuple[List[str], List[str]]: return namespace_tags, freeform_tags +def _extract_namespace_sort_values(tags: Any, namespace: str) -> List[str]: + wanted = str(namespace or "").strip().casefold() + if not wanted: + return [] + + values: List[str] = [] + for tag in _normalize_detail_tags(tags): + ns, sep, value = str(tag).partition(":") + if not sep: + continue + if ns.strip().casefold() != wanted: + continue + clean_value = value.strip() + if clean_value: + values.append(clean_value) + return values + + +def _namespace_sort_key(tags: Any, namespace: str, *, numeric: bool = False) -> Tuple[int, Any, str]: + values = _extract_namespace_sort_values(tags, namespace) + if not values: + return (1, float("inf") if numeric else "", "") + + primary = values[0] + if numeric: + match = re.search(r"-?\d+(?:\.\d+)?", primary) + if match: + try: + return (0, float(match.group(0)), primary.casefold()) + except Exception: + pass + return (0, float("inf"), primary.casefold()) + + return (0, primary.casefold(), primary.casefold()) + + def _chunk_detail_tags(tags: List[str], columns: int) -> List[List[str]]: column_count = max(1, int(columns or 1)) rows: List[List[str]] = [] @@ -954,6 +990,41 @@ class Table: return self + def sort_by_tag_namespace(self, namespace: str, *, numeric: bool = False, reverse: bool = False) -> "Table": + """Sort rows by the first value found for a tag namespace. + + Looks first at row payload tag metadata, then falls back to the visible Tag column. + When ``numeric`` is True, the first numeric token inside the namespace value is used. + """ + if getattr(self, "preserve_order", False): + return self + + wanted = str(namespace or "").strip() + if not wanted: + return self + + def _row_tags(row: Row) -> Any: + payload = getattr(row, "payload", None) + if isinstance(payload, dict): + for key in ("tag", "tags", "tag_summary"): + value = payload.get(key) + if value: + return value + metadata = payload.get("metadata") + if isinstance(metadata, dict): + for key in ("tag", "tags", "tag_summary"): + value = metadata.get(key) + if value: + return value + tag_column = row.get_column("Tag") + return tag_column or [] + + self.rows.sort( + key=lambda row: _namespace_sort_key(_row_tags(row), wanted, numeric=bool(numeric)), + reverse=bool(reverse), + ) + return self + def add_result(self, result: Any) -> "Table": """Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row. diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py deleted file mode 100644 index ecc7fc7..0000000 --- a/Store/HydrusNetwork.py +++ /dev/null @@ -1,2702 +0,0 @@ -from __future__ import annotations - -import re -import sys -import tempfile -import shutil -from collections.abc import Mapping, Sequence as SequenceABC -from collections import deque -from pathlib import Path -from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple - -from urllib.parse import quote, parse_qsl, urlencode, urlsplit, urlunsplit - -import httpx -from API.httpx_shared import get_shared_httpx_client - -from SYS.logger import debug, debug_panel, log -from SYS.utils_constant import mime_maps - -_KNOWN_EXTS = { - str(info.get("ext") or "").strip().lstrip(".") - for category in mime_maps.values() - for info in category.values() - if isinstance(info, dict) and info.get("ext") -} - - -def _resolve_ext_from_meta(meta: Dict[str, Any], mime_type: Optional[str]) -> str: - ext = "" - for key in ("ext", "file_ext", "extension", "file_extension"): - raw = meta.get(key) - if raw: - ext = str(raw).strip().lstrip(".") - break - if ext and ext not in _KNOWN_EXTS: - ext = "" - if ext.lower() == "ebook": - ext = "" - - if not ext: - filetype_human = ( - meta.get("filetype_human") - or meta.get("mime_human") - or meta.get("mime_string") - or meta.get("filetype") - ) - ft = str(filetype_human or "").strip().lstrip(".").lower() - if ft and ft != "unknown filetype": - if ft.isalnum() and len(ft) <= 8: - ext = ft - else: - try: - for token in re.findall(r"[a-z0-9]+", ft): - if token in _KNOWN_EXTS: - ext = token - break - except Exception: - pass - - if not ext: - if not mime_type or not isinstance(mime_type, str) or "/" not in mime_type: - mime_type = meta.get("mime_string") or meta.get("mime_human") or meta.get("filetype_mime") or mime_type - - if not ext and mime_type: - try: - mime_type = str(mime_type).split(";", 1)[0].strip().lower() - except Exception: - mime_type = str(mime_type) - for category in mime_maps.values(): - for _ext_key, info in category.items(): - if mime_type in info.get("mimes", []): - ext = str(info.get("ext", "")).strip().lstrip(".") - break - if ext: - break - return ext - -from Store._base import Store - -_HYDRUS_INIT_CHECK_CACHE: dict[tuple[str, - str], - tuple[bool, - Optional[str]]] = {} - - -class HydrusNetwork(Store): - """File storage backend for Hydrus client. - - Each instance represents a specific Hydrus client connection. - Maintains its own HydrusClient. - """ - - STORE_TYPE = "hydrusnetwork" - - @classmethod - def config_schema(cls) -> List[Dict[str, Any]]: - return [ - { - "key": "NAME", - "label": "Store Name", - "default": "", - "placeholder": "e.g. home_hydrus", - "required": True - }, - { - "key": "URL", - "label": "Hydrus URL", - "default": "http://127.0.0.1:45869", - "placeholder": "http://127.0.0.1:45869", - "required": True - }, - { - "key": "API", - "label": "API Key", - "default": "", - "required": True, - "secret": True - } - ] - - @property - def is_remote(self) -> bool: - return True - - @property - def prefer_defer_tags(self) -> bool: - return True - - def _log_prefix(self) -> str: - store_name = getattr(self, "NAME", None) or "unknown" - return f"[hydrusnetwork:{store_name}]" - - def _append_access_key(self, url: str) -> str: - if not url: - return url - if "access_key=" in url: - return url - if not getattr(self, "API", None): - return url - separator = "&" if "?" in url else "?" - return f"{url}{separator}access_key={quote(str(self.API))}" - - def __new__(cls, *args: Any, **kwargs: Any) -> "HydrusNetwork": - instance = super().__new__(cls) - name = kwargs.get("NAME") - api = kwargs.get("API") - url = kwargs.get("URL") - if name is not None: - setattr(instance, "NAME", str(name)) - if api is not None: - setattr(instance, "API", str(api)) - if url is not None: - setattr(instance, "URL", str(url)) - return instance - - def __init__( - self, - instance_name: Optional[str] = None, - api_key: Optional[str] = None, - url: Optional[str] = None, - *, - NAME: Optional[str] = None, - API: Optional[str] = None, - URL: Optional[str] = None, - ) -> None: - """Initialize Hydrus storage backend. - - Args: - instance_name: Name of this Hydrus instance (e.g., 'home', 'work') - api_key: Hydrus Client API access key - url: Hydrus client URL (e.g., 'http://192.168.1.230:45869') - """ - from plugins.hydrusnetwork.api import HydrusNetwork as HydrusClient - - if instance_name is None and NAME is not None: - instance_name = str(NAME) - if api_key is None and API is not None: - api_key = str(API) - if url is None and URL is not None: - url = str(URL) - - if not instance_name or not api_key or not url: - raise ValueError("HydrusNetwork requires NAME, API, and URL") - - self.NAME = instance_name - self.API = api_key - self.URL = url.rstrip("/") - - # Total count (best-effort, used for startup diagnostics) - self.total_count: Optional[int] = None - - # Self health-check: validate the URL is reachable and the access key is accepted. - # This MUST NOT attempt to acquire a session key. - cache_key = (self.URL, self.API) - cached = _HYDRUS_INIT_CHECK_CACHE.get(cache_key) - if cached is not None: - ok, err = cached - if not ok: - raise RuntimeError( - f"Hydrus '{self.NAME}' unavailable: {err or 'Unavailable'}" - ) - else: - api_version_url = f"{self.URL}/api_version" - verify_key_url = f"{self.URL}/verify_access_key" - try: - client = get_shared_httpx_client(timeout=5.0, verify_ssl=False) - version_resp = client.get(api_version_url, follow_redirects=True) - version_resp.raise_for_status() - version_payload = version_resp.json() - if not isinstance(version_payload, dict): - raise RuntimeError( - "Hydrus /api_version returned an unexpected response" - ) - - verify_resp = client.get( - verify_key_url, - headers={ - "Hydrus-Client-API-Access-Key": self.API - }, - follow_redirects=True, - ) - verify_resp.raise_for_status() - verify_payload = verify_resp.json() - if not isinstance(verify_payload, dict): - raise RuntimeError( - "Hydrus /verify_access_key returned an unexpected response" - ) - - _HYDRUS_INIT_CHECK_CACHE[cache_key] = (True, None) - except Exception as exc: - err = str(exc) - _HYDRUS_INIT_CHECK_CACHE[cache_key] = (False, err) - raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {err}") from exc - - # Create a persistent client for this instance (auth via access key by default). - self._client = HydrusClient( - url=self.URL, - access_key=self.API, - instance_name=self.NAME - ) - - self._service_key_cache: Dict[str, Optional[str]] = {} - - # Best-effort total count (used for startup diagnostics). Avoid heavy payloads. - # Some Hydrus setups appear to return no count via the CBOR client for this endpoint, - # so prefer a direct JSON request with a short timeout. - # NOTE: Disabled to avoid unnecessary API call during init; count will be retrieved on first search/list if needed. - # try: - # self.get_total_count(refresh=True) - # except Exception: - # pass - - def _get_service_key(self, service_name: str, *, refresh: bool = False) -> Optional[str]: - """Resolve (and cache) the Hydrus service key for the given service name.""" - normalized = str(service_name or "my tags").strip() - if not normalized: - normalized = "my tags" - cache_key = normalized.lower() - if not refresh and cache_key in self._service_key_cache: - return self._service_key_cache[cache_key] - - client = self._client - if client is None: - self._service_key_cache[cache_key] = None - return None - - try: - from API import HydrusNetwork as hydrus_wrapper - - resolved = hydrus_wrapper.get_tag_service_key(client, normalized) - except Exception: - resolved = None - - self._service_key_cache[cache_key] = resolved - return resolved - - def get_total_count(self, *, refresh: bool = False) -> Optional[int]: - """Best-effort total file count for this Hydrus instance. - - Intended for diagnostics (e.g., REPL startup checks). This should be fast, - and it MUST NOT raise. - """ - if self.total_count is not None and not refresh: - return self.total_count - - # 1) Prefer a direct JSON request (fast + avoids CBOR edge cases). - try: - import json as _json - - url = f"{self.URL}/get_files/search_files" - params = { - "tags": _json.dumps(["system:everything"]), - "return_hashes": "false", - "return_file_ids": "false", - "return_file_count": "true", - } - headers = { - "Hydrus-Client-API-Access-Key": self.API, - "Accept": "application/json", - } - client = get_shared_httpx_client(timeout=5.0, verify_ssl=False) - resp = client.get(url, params=params, headers=headers, follow_redirects=True) - resp.raise_for_status() - payload = resp.json() - - count_val = None - if isinstance(payload, dict): - count_val = payload.get("file_count") - if count_val is None: - count_val = payload.get("file_count_inclusive") - if count_val is None: - count_val = payload.get("num_files") - if isinstance(count_val, int): - self.total_count = count_val - return self.total_count - except Exception as exc: - debug( - f"{self._log_prefix()} total count (json) unavailable: {exc}", - file=sys.stderr - ) - - # 2) Fallback to the API client (CBOR). - try: - payload = self._client.search_files( - tags=["system:everything"], - return_hashes=False, - return_file_ids=False, - return_file_count=True, - ) - count_val = None - if isinstance(payload, dict): - count_val = payload.get("file_count") - if count_val is None: - count_val = payload.get("file_count_inclusive") - if count_val is None: - count_val = payload.get("num_files") - if isinstance(count_val, int): - self.total_count = count_val - return self.total_count - except Exception as exc: - debug( - f"{self._log_prefix()} total count (client) unavailable: {exc}", - file=sys.stderr - ) - - return self.total_count - - def name(self) -> str: - return self.NAME - - def get_name(self) -> str: - return self.NAME - - def set_relationship(self, alt_hash: str, king_hash: str, kind: str = "alt") -> bool: - """Persist a relationship via the Hydrus client API for this backend instance.""" - try: - alt_norm = str(alt_hash or "").strip().lower() - king_norm = str(king_hash or "").strip().lower() - if len(alt_norm) != 64 or len(king_norm) != 64 or alt_norm == king_norm: - return False - - client = getattr(self, "_client", None) - if client is None or not hasattr(client, "set_relationship"): - return False - - client.set_relationship(alt_norm, king_norm, str(kind or "alt")) - return True - except Exception: - return False - - @staticmethod - def _has_current_file_service(meta: Dict[str, Any]) -> bool: - services = meta.get("file_services") - if not isinstance(services, dict): - return False - current = services.get("current") - if isinstance(current, dict): - return any(bool(v) for v in current.values()) - if isinstance(current, list): - return len(current) > 0 - return False - - def add_file(self, file_path: Path, **kwargs: Any) -> str: - """Upload file to Hydrus with full metadata support. - - Args: - file_path: Path to the file to upload - tag: Optional list of tag values to add - url: Optional list of url to associate with the file - title: Optional title (will be added as 'title:value' tag) - - Returns: - File hash from Hydrus - - Raises: - Exception: If upload fails - """ - from SYS.utils import sha256_file - - tag_list = kwargs.get("tag", []) - url = kwargs.get("url", []) - title = kwargs.get("title") - - # Add title to tags if provided and not already present - if title: - title_tag = f"title:{title}".strip().lower() - if not any(str(candidate).lower().startswith("title:") - for candidate in tag_list): - tag_list = [title_tag] + list(tag_list) - - # Hydrus is lowercase-only tags; normalize here for consistency. - tag_list = [ - str(t).strip().lower() for t in (tag_list or []) - if isinstance(t, str) and str(t).strip() - ] - - try: - # Compute file hash (or use hint from kwargs to avoid redundant IO) - file_hash = kwargs.get("hash") or kwargs.get("file_hash") - if not file_hash: - file_hash = sha256_file(file_path) - - # Use persistent client with session key - client = self._client - if client is None: - raise Exception("Hydrus client unavailable") - - # Check if file already exists in Hydrus. - # IMPORTANT: some Hydrus deployments can return a metadata record (file_id) - # even when the file is not in any current file service (e.g. trashed/missing). - # Only treat as a real duplicate if it is in a current file service. - file_exists = False - try: - metadata = client.fetch_file_metadata( - hashes=[file_hash], - include_service_keys_to_tags=False, - include_file_services=True, - include_is_trashed=True, - include_file_url=True, - include_duration=False, - include_size=True, - include_mime=True, - ) - if metadata and isinstance(metadata, dict): - metas = metadata.get("metadata", []) - if isinstance(metas, list) and metas: - # Hydrus returns placeholder rows for unknown hashes. - # Only treat as a real duplicate if it has a concrete file_id AND - # appears in a current file service. - for meta in metas: - if not isinstance(meta, dict): - continue - if meta.get("file_id") is None: - continue - # Preferred: use file_services.current. - if isinstance(meta.get("file_services"), dict): - if self._has_current_file_service(meta): - file_exists = True - break - continue - - # Fallback: if Hydrus doesn't return file_services, only treat as - # existing when the metadata looks like a real file (non-zero size). - size_val = meta.get("size") - if size_val is None: - size_val = meta.get("size_bytes") - try: - size_int = int(size_val) if size_val is not None else 0 - except Exception: - size_int = 0 - if size_int > 0: - file_exists = True - break - if file_exists: - debug( - f"{self._log_prefix()} Duplicate detected - file already in Hydrus with hash: {file_hash}" - ) - except Exception as exc: - debug(f"{self._log_prefix()} metadata fetch failed: {exc}") - - # If Hydrus reports an existing file, it may be in trash. Best-effort restore it to 'my files'. - # Then re-check that it is actually in a current file service; if not, we'll proceed to upload. - if file_exists: - try: - client.undelete_files([file_hash]) - except Exception: - pass - - try: - metadata2 = client.fetch_file_metadata( - hashes=[file_hash], - include_service_keys_to_tags=False, - include_file_services=True, - include_is_trashed=True, - include_file_url=False, - include_duration=False, - include_size=False, - include_mime=False, - ) - metas2 = metadata2.get("metadata", []) if isinstance(metadata2, dict) else [] - if isinstance(metas2, list) and metas2: - still_current = False - for meta in metas2: - if not isinstance(meta, dict): - continue - if meta.get("file_id") is None: - continue - if isinstance(meta.get("file_services"), dict): - if self._has_current_file_service(meta): - still_current = True - break - continue - - size_val = meta.get("size") - if size_val is None: - size_val = meta.get("size_bytes") - try: - size_int = int(size_val) if size_val is not None else 0 - except Exception: - size_int = 0 - if size_int > 0: - still_current = True - break - if not still_current: - file_exists = False - except Exception: - # If re-check fails, keep prior behavior (avoid forcing uploads in unknown states) - pass - - # Upload file if not already present - if not file_exists: - response = client.add_file(file_path) - - # Extract hash from response - hydrus_hash: Optional[str] = None - if isinstance(response, dict): - hydrus_hash = response.get("hash") or response.get("file_hash") - if not hydrus_hash: - hashes = response.get("hashes") - if isinstance(hashes, list) and hashes: - hydrus_hash = hashes[0] - - if isinstance(hydrus_hash, (bytes, bytearray)): - try: - hydrus_hash = bytes(hydrus_hash).hex() - except Exception: - hydrus_hash = None - - if hydrus_hash: - try: - hydrus_hash = str(hydrus_hash).strip().lower() - except Exception: - hydrus_hash = None - - if not hydrus_hash or len(str(hydrus_hash)) != 64: - debug_panel( - "Hydrus upload fallback", - [ - ("store", self.NAME), - ("file", file_path.name), - ("reason", "response hash missing/invalid"), - ("fallback_hash", file_hash), - ], - border_style="yellow", - ) - hydrus_hash = file_hash - - if not hydrus_hash: - raise Exception(f"Hydrus response missing file hash: {response}") - - file_hash = hydrus_hash - - # Add tags if provided (both for new and existing files) - if tag_list: - try: - # Use default tag service - service_name = "my tags" - except Exception: - service_name = "my tags" - - try: - client.add_tag(file_hash, tag_list, service_name) - except Exception as exc: - log( - f"{self._log_prefix()} ⚠️ Failed to add tags: {exc}", - file=sys.stderr - ) - - # Associate url if provided (both for new and existing files) - if url: - for url in url: - if url: - try: - client.associate_url(file_hash, str(url)) - except Exception as exc: - log( - f"{self._log_prefix()} ⚠️ Failed to associate URL {url}: {exc}", - file=sys.stderr, - ) - - return file_hash - - except Exception as exc: - log(f"{self._log_prefix()} ❌ upload failed: {exc}", file=sys.stderr) - raise - - def search(self, query: str, **kwargs: Any) -> list[Dict[str, Any]]: - """Search Hydrus database for files matching query. - - Args: - query: Search query (tags, filenames, hashes, etc.) - limit: Maximum number of results to return (default: 100) - - Returns: - List of dicts with 'name', 'hash', 'size', 'tags' fields - - Example: - results = storage["hydrus"].search("artist:john_doe music") - results = storage["hydrus"].search("Simple Man") - """ - limit = kwargs.get("limit", 100) - minimal = bool(kwargs.get("minimal", False)) - url_only = bool(kwargs.get("url_only", False)) - - try: - client = self._client - if client is None: - raise Exception("Hydrus client unavailable") - - prefix = self._log_prefix() - - def _extract_urls(meta_obj: Any) -> list[str]: - if not isinstance(meta_obj, dict): - return [] - raw = meta_obj.get("known_urls") - if raw is None: - raw = meta_obj.get("url") - if raw is None: - raw = meta_obj.get("urls") - if isinstance(raw, str): - val = raw.strip() - return [val] if val else [] - if isinstance(raw, list): - out: list[str] = [] - for item in raw: - if not isinstance(item, str): - continue - s = item.strip() - if s: - out.append(s) - return out - return [] - - def _extract_search_ids(payload: Any) -> tuple[list[int], list[str]]: - if not isinstance(payload, dict): - return [], [] - raw_ids = payload.get("file_ids", []) - raw_hashes = payload.get("hashes", []) - ids_out: list[int] = [] - hashes_out: list[str] = [] - if isinstance(raw_ids, list): - for item in raw_ids: - try: - if isinstance(item, (int, float)): - ids_out.append(int(item)) - continue - if isinstance(item, str) and item.strip().isdigit(): - ids_out.append(int(item.strip())) - except Exception: - continue - if isinstance(raw_hashes, list): - for item in raw_hashes: - try: - candidate = str(item or "").strip().lower() - if candidate: - hashes_out.append(candidate) - except Exception: - continue - return ids_out, hashes_out - - def _fetch_search_metadata( - *, - file_ids: Optional[Sequence[Any]] = None, - hashes: Optional[Sequence[Any]] = None, - include_tags: bool = True, - include_urls: bool = True, - include_mime: bool = True, - ) -> list[dict[str, Any]]: - try: - payload = client.fetch_file_metadata( - file_ids=file_ids, - hashes=hashes, - include_service_keys_to_tags=include_tags, - include_file_url=include_urls, - include_duration=False, - include_size=True, - include_mime=include_mime, - ) - except Exception: - return [] - - metadata = payload.get("metadata", []) if isinstance(payload, dict) else [] - return metadata if isinstance(metadata, list) else [] - - def _iter_url_filtered_metadata( - url_value: str | None, - want_any: bool, - fetch_limit: int, - scan_limit: int | None = None, - needles: Optional[Sequence[str]] = None, - *, - minimal: bool = False, - ) -> list[dict[str, Any]]: - """Best-effort URL search by scanning Hydrus metadata with include_file_url=True.""" - - try: - from plugins.hydrusnetwork.api import _generate_hydrus_url_variants - except Exception: - _generate_hydrus_url_variants = None # type: ignore[assignment] - - def _normalize_url_match_token(value: str | None) -> str: - token = str(value or "").strip() - if not token: - return "" - - token = token.split("#", 1)[0] - try: - parsed_url = urlsplit(token) - except Exception: - return token.lower() - - if parsed_url.scheme and parsed_url.scheme.lower() not in {"http", "https"}: - return token.lower() - - netloc = str(parsed_url.netloc or "").strip().lower() - if netloc.startswith("www."): - netloc = netloc[4:] - - try: - query_pairs = parse_qsl(parsed_url.query, keep_blank_values=True) - except Exception: - query_pairs = [] - - filtered_pairs = [] - for key, val in query_pairs: - key_norm = str(key or "").lower() - if key_norm in {"t", "start", "time_continue", "timestamp", "time", "begin"}: - continue - if key_norm.startswith("utm_"): - continue - filtered_pairs.append((key, val)) - - normalized_query = urlencode(filtered_pairs, doseq=True) if filtered_pairs else "" - normalized = urlunsplit(("", netloc, parsed_url.path or "", normalized_query, "")) - return str(normalized or token).lstrip("/").lower() - - def _append_url_needles(output: list[str], candidate: str | None) -> None: - raw_candidate = str(candidate or "").strip() - if not raw_candidate: - return - - expanded_candidates = [raw_candidate] - if callable(_generate_hydrus_url_variants): - try: - expanded_candidates.extend(_generate_hydrus_url_variants(raw_candidate) or []) - except Exception: - pass - - for expanded in expanded_candidates: - expanded_text = str(expanded or "").strip() - if not expanded_text: - continue - lowered = expanded_text.lower() - if lowered and lowered not in output: - output.append(lowered) - normalized = _normalize_url_match_token(expanded_text) - if normalized and normalized not in output: - output.append(normalized) - - candidate_file_ids: list[int] = [] - candidate_hashes: list[str] = [] - seen_file_ids: set[int] = set() - seen_hashes: set[str] = set() - - def _add_candidates(ids: list[int], hashes: list[str]) -> None: - for fid in ids: - if fid in seen_file_ids: - continue - seen_file_ids.add(fid) - candidate_file_ids.append(fid) - for hh in hashes: - if hh in seen_hashes: - continue - seen_hashes.add(hh) - candidate_hashes.append(hh) - - predicate_supported = getattr(self, "_has_url_predicate", None) - if predicate_supported is not False: - try: - predicate = "system:has url" - url_search = client.search_files( - tags=[predicate], - return_hashes=True, - return_file_ids=False, - return_file_count=False, - ) - ids, hashes = _extract_search_ids(url_search) - _add_candidates(ids, hashes) - self._has_url_predicate = True - except Exception as exc: - try: - from plugins.hydrusnetwork.api import HydrusRequestError - - if isinstance(exc, HydrusRequestError) and getattr(exc, "status", None) == 400: - self._has_url_predicate = False - except Exception: - pass - - if not candidate_file_ids and not candidate_hashes: - everything = client.search_files( - tags=["system:everything"], - return_hashes=True, - return_file_ids=False, - return_file_count=False, - ) - ids, hashes = _extract_search_ids(everything) - _add_candidates(ids, hashes) - - if not candidate_file_ids and not candidate_hashes: - return [] - - needle_list: list[str] = [] - if isinstance(needles, (list, tuple, set)): - for item in needles: - _append_url_needles(needle_list, str(item or "")) - if not needle_list: - _append_url_needles(needle_list, url_value) - chunk_size = 200 - out: list[dict[str, Any]] = [] - if scan_limit is None: - try: - if not want_any and needle_list: - if len(needle_list) > 1: - scan_limit = max(int(fetch_limit) * 20, 2000) - else: - scan_limit = max(200, min(int(fetch_limit), 400)) - else: - scan_limit = max(int(fetch_limit) * 5, 1000) - except Exception: - scan_limit = 400 if (not want_any and needle_list) else 1000 - if scan_limit is not None: - scan_limit = min(int(scan_limit), 10000) - scanned = 0 - - def _process_source(items: list[Any], kind: str) -> None: - nonlocal scanned - for start in range(0, len(items), chunk_size): - if len(out) >= fetch_limit: - return - if scan_limit is not None and scanned >= scan_limit: - return - chunk = items[start:start + chunk_size] - if scan_limit is not None: - remaining = scan_limit - scanned - if remaining <= 0: - return - if len(chunk) > remaining: - chunk = chunk[:remaining] - scanned += len(chunk) - try: - if kind == "hashes": - payload = client.fetch_file_metadata( - hashes=chunk, - include_file_url=True, - include_service_keys_to_tags=not minimal, - include_duration=not minimal, - include_size=not minimal, - include_mime=not minimal, - ) - else: - payload = client.fetch_file_metadata( - file_ids=chunk, - include_file_url=True, - include_service_keys_to_tags=not minimal, - include_duration=not minimal, - include_size=not minimal, - include_mime=not minimal, - ) - except Exception: - continue - - metas = payload.get("metadata", - []) if isinstance(payload, - dict) else [] - if not isinstance(metas, list): - continue - - for meta in metas: - if len(out) >= fetch_limit: - break - if not isinstance(meta, dict): - continue - urls = _extract_urls(meta) - if not urls: - continue - if want_any: - out.append(meta) - continue - if not needle_list: - continue - normalized_urls = [_normalize_url_match_token(u) for u in urls] - if any( - any( - n in str(u or "").lower() or (normalized_urls[idx] and n in normalized_urls[idx]) - for idx, u in enumerate(urls) - ) - for n in needle_list - ): - out.append(meta) - continue - - sources: list[tuple[str, list[Any]]] = [] - if candidate_hashes: - sources.append(("hashes", candidate_hashes)) - elif candidate_file_ids: - sources.append(("file_ids", candidate_file_ids)) - - for kind, items in sources: - if len(out) >= fetch_limit: - break - _process_source(items, kind) - - return out - - def _search_url_query_metadata( - url_query: str, - fetch_limit: int, - *, - minimal: bool = False, - ) -> list[dict[str, Any]]: - """Run a strict url: search without falling back to system predicates.""" - - if not url_query: - return [] - - try: - payload = client.search_files( - tags=[url_query], - return_hashes=True, - return_file_ids=True, - ) - except Exception: - return [] - - candidate_ids, candidate_hashes = _extract_search_ids(payload) - if not candidate_ids and not candidate_hashes: - return [] - - metas_out: list[dict[str, Any]] = [] - chunk_size = 200 - - def _fetch_chunk(kind: Literal["file_ids", "hashes"], values: list[Any]) -> None: - nonlocal metas_out - if not values or len(metas_out) >= fetch_limit: - return - for start in range(0, len(values), chunk_size): - if len(metas_out) >= fetch_limit: - break - remaining = fetch_limit - len(metas_out) - if remaining <= 0: - break - end = start + min(chunk_size, remaining) - chunk = values[start:end] - if not chunk: - continue - try: - if kind == "file_ids": - metadata = client.fetch_file_metadata( - file_ids=chunk, - include_file_url=True, - include_service_keys_to_tags=False, - include_duration=False, - include_size=not minimal, - include_mime=False, - ) - else: - metadata = client.fetch_file_metadata( - hashes=chunk, - include_file_url=True, - include_service_keys_to_tags=False, - include_duration=False, - include_size=not minimal, - include_mime=False, - ) - except Exception: - continue - - fetched = metadata.get("metadata", []) if isinstance(metadata, dict) else [] - if not isinstance(fetched, list): - continue - for meta in fetched: - if len(metas_out) >= fetch_limit: - break - if not isinstance(meta, dict): - continue - metas_out.append(meta) - - if candidate_ids: - _fetch_chunk("file_ids", candidate_ids) - if len(metas_out) < fetch_limit and candidate_hashes: - _fetch_chunk("hashes", candidate_hashes) - - return metas_out[:fetch_limit] - - def _cap_metadata_candidates( - file_ids_in: list[int], - hashes_in: list[str], - *, - requested_limit: Any, - freeform_mode: bool = False, - fallback_scan: bool = False, - ) -> tuple[list[int], list[str]]: - """Cap metadata hydration to a sane subset of Hydrus hits. - - Hydrus native tag search is fast, but fetching metadata for every - matched file can explode for broad queries. Keep the native search, - but only hydrate a bounded working set and let downstream filtering - stop once enough display rows are collected. - """ - - try: - base_limit = int(requested_limit or 100) - except Exception: - base_limit = 100 - if base_limit <= 0: - base_limit = 100 - - hydrate_limit = base_limit - if freeform_mode: - hydrate_limit = max(hydrate_limit * 4, 200) - if fallback_scan: - hydrate_limit = max(hydrate_limit * 2, 200) - hydrate_limit = min(hydrate_limit, 1000) - - ids_out = list(file_ids_in or []) - hashes_out = list(hashes_in or []) - total_candidates = len(ids_out) + len(hashes_out) - if total_candidates <= hydrate_limit: - return ids_out, hashes_out - - debug_panel( - "Hydrus metadata hydration cap", - [ - ("store", self.NAME), - ("candidates", total_candidates), - ("hydrate_limit", hydrate_limit), - ("freeform_mode", freeform_mode), - ("fallback_scan", fallback_scan), - ], - border_style="cyan", - ) - - if ids_out: - ids_out = ids_out[:hydrate_limit] - remaining = max(0, hydrate_limit - len(ids_out)) - hashes_out = hashes_out[:remaining] if remaining > 0 else [] - else: - hashes_out = hashes_out[:hydrate_limit] - - return ids_out, hashes_out - - raw_query = str(query or "").strip() - query_lower = raw_query.lower().strip() - - # Support `ext:` anywhere in the query. We filter results by the - # Hydrus metadata extension field. - def _normalize_ext_filter(value: str) -> str: - v = str(value or "").strip().lower().lstrip(".") - v = "".join(ch for ch in v if ch.isalnum()) - return v - - ext_filter: str | None = None - ext_only: bool = False - try: - m = re.search(r"\bext:([^\s,]+)", query_lower) - if not m: - m = re.search(r"\bextension:([^\s,]+)", query_lower) - if m: - ext_filter = _normalize_ext_filter(m.group(1)) or None - query_lower = re.sub( - r"\s*\b(?:ext|extension):[^\s,]+", - " ", - query_lower - ) - query_lower = re.sub(r"\s{2,}", " ", query_lower).strip().strip(",") - query = query_lower - if ext_filter and not query_lower: - query = "*" - query_lower = "*" - ext_only = True - except Exception: - ext_filter = None - ext_only = False - - # Split into meaningful terms for AND logic. - # Avoid punctuation tokens like '-' that would make matching brittle. - search_terms = [t for t in re.findall(r"[a-z0-9]+", query_lower) if t] - - # Special case: url:* and url: - metadata_list: list[dict[str, Any]] | None = None - pattern_hint_raw = kwargs.get("pattern_hint") - pattern_hints: list[str] = [] - if isinstance(pattern_hint_raw, (list, tuple, set)): - for item in pattern_hint_raw: - text = str(item or "").strip().lower() - if text and text not in pattern_hints: - pattern_hints.append(text) - elif isinstance(pattern_hint_raw, str): - text = pattern_hint_raw.strip().lower() - if text: - pattern_hints.append(text) - pattern_hint = pattern_hints[0] if pattern_hints else "" - - hashes: list[str] = [] - file_ids: list[int] = [] - - if ":" in raw_query and not raw_query.startswith(":"): - namespace_raw, pattern_raw = raw_query.split(":", 1) - namespace = namespace_raw.strip().lower() - pattern = pattern_raw.strip() - if namespace == "url": - try: - fetch_limit_raw = int(limit) if limit else 100 - except Exception: - fetch_limit_raw = 100 - if url_only: - metadata_list = _search_url_query_metadata( - f"url:{pattern}", - fetch_limit_raw, - minimal=minimal, - ) - else: - if not pattern or pattern == "*": - if pattern_hints: - metadata_list = _iter_url_filtered_metadata( - None, - want_any=False, - fetch_limit=fetch_limit_raw, - needles=pattern_hints, - minimal=minimal, - ) - else: - metadata_list = _iter_url_filtered_metadata( - None, - want_any=True, - fetch_limit=fetch_limit_raw, - minimal=minimal, - ) - else: - def _clean_url_search_token(value: str | None) -> str: - token = str(value or "").strip().lower() - if not token: - return "" - return token.replace("*", "") - - # Fast-path: exact URL via /add_urls/get_url_files when a full URL is provided. - exact_url_attempted = False - try: - has_wildcards = ("*" in pattern) - if (pattern.startswith("http://") or pattern.startswith("https://")) and not has_wildcards: - exact_url_attempted = True - metadata_list = self.lookup_url_metadata(pattern, minimal=minimal) - except Exception: - metadata_list = [] if exact_url_attempted else None - - # Fallback: substring scan - if metadata_list is None: - search_token = _clean_url_search_token(pattern_hint or pattern) - scan_limit_override: int | None = None - if search_token: - is_domain_only = ("://" not in search_token and "/" not in search_token) - if is_domain_only: - try: - scan_limit_override = max(fetch_limit_raw * 20, 2000) - except Exception: - scan_limit_override = 2000 - metadata_list = _iter_url_filtered_metadata( - search_token, - want_any=False, - fetch_limit=fetch_limit_raw, - scan_limit=scan_limit_override, - needles=pattern_hints if pattern_hints else None, - minimal=minimal, - ) - elif namespace == "system": - normalized_system_predicate = pattern.strip().lower() - if normalized_system_predicate == "has url": - try: - fetch_limit = int(limit) if limit else 100 - except Exception: - fetch_limit = 100 - metadata_list = _iter_url_filtered_metadata( - None, - want_any=not bool(pattern_hints), - fetch_limit=fetch_limit, - needles=pattern_hints if pattern_hints else None, - minimal=minimal, - ) - - # Parse the query into tags - # "*" means "match all" - use system:everything tag in Hydrus - # If query has explicit namespace, use it as a tag search. - # If query is free-form, search BOTH: - # - title:*term* (title: is the only namespace searched implicitly) - # - *term* (freeform tags; we will filter out other namespace matches client-side) - tags: list[str] = [] - freeform_union_search: bool = False - title_predicates: list[str] = [] - freeform_predicates: list[str] = [] - - if query.strip() == "*": - tags = ["system:everything"] - elif ":" in query_lower: - tags = [query_lower] - else: - freeform_union_search = True - if search_terms: - # Hydrus supports wildcard matching primarily as a prefix (e.g., tag*). - # Use per-term prefix matching for both title: and freeform tags. - title_predicates = [f"title:{term}*" for term in search_terms] - freeform_predicates = [f"{term}*" for term in search_terms] - else: - # If we can't extract alnum terms, fall back to the raw query text. - title_predicates = [f"title:{query_lower}*"] - freeform_predicates = [f"{query_lower}*"] - - # Search files with the tags (unless url: search already produced metadata) - results: list[dict[str, Any]] = [] - - if metadata_list is None: - file_ids = [] - hashes = [] - - if freeform_union_search: - if not title_predicates and not freeform_predicates: - return [] - - payloads: list[Any] = [] - try: - payloads.append( - client.search_files( - tags=title_predicates, - return_hashes=False, - return_file_ids=True, - ) - ) - except Exception: - pass - - # Extra pass: match a full title phrase when the query includes - # spaces or punctuation (e.g., "i've been down"). - try: - if query_lower and query_lower != "*" and "*" not in query_lower: - if any(ch in query_lower for ch in (" ", "'", "-", "_")): - payloads.append( - client.search_files( - tags=[f"title:{query_lower}*"], - return_hashes=False, - return_file_ids=True, - ) - ) - except Exception: - pass - - try: - payloads.append( - client.search_files( - tags=freeform_predicates, - return_hashes=False, - return_file_ids=True, - ) - ) - except Exception: - pass - - id_set: set[int] = set() - for payload in payloads: - ids_part, _ = _extract_search_ids(payload) - for fid in ids_part: - id_set.add(fid) - file_ids = list(id_set) - hashes = [] - else: - if not tags: - return [] - - search_result = client.search_files( - tags=tags, - return_hashes=False, - return_file_ids=True - ) - file_ids, _ = _extract_search_ids(search_result) - hashes = [] - - # Fast path: ext-only search. Avoid fetching metadata for an unbounded - # system:everything result set; fetch in chunks until we have enough. - if ext_only and ext_filter: - results = [] - if not file_ids and not hashes: - return [] - - # Prefer file_ids if available. - if file_ids: - chunk_size = 200 - for start in range(0, len(file_ids), chunk_size): - if len(results) >= limit: - break - chunk = file_ids[start:start + chunk_size] - metas = _fetch_search_metadata( - file_ids=chunk, - include_tags=True, - include_urls=True, - include_mime=True, - ) - if not metas: - continue - for meta in metas: - if len(results) >= limit: - break - if not isinstance(meta, dict): - continue - mime_type = meta.get("mime") - ext = _resolve_ext_from_meta(meta, mime_type) - if _normalize_ext_filter(ext) != ext_filter: - continue - - file_id = meta.get("file_id") - hash_hex = meta.get("hash") - size_val = meta.get("size") - if size_val is None: - size_val = meta.get("size_bytes") - try: - size = int(size_val) if size_val is not None else 0 - except Exception: - size = 0 - - title, all_tags = self._extract_title_and_tags(meta, file_id) - - # Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet) - item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or [] - if not item_url: - item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}" - if isinstance(item_url, str) and "/view_file" in item_url: - item_url = self._append_access_key(item_url) - - results.append( - { - "hash": hash_hex, - "url": item_url, - "name": title, - "title": title, - "size": size, - "size_bytes": size, - "store": self.NAME, - "tag": all_tags, - "file_id": file_id, - "mime": mime_type, - "ext": _resolve_ext_from_meta(meta, mime_type), - } - ) - return results[:limit] - - # If we only got hashes, fall back to the normal flow below. - - if not file_ids and not hashes: - return [] - - file_ids, hashes = _cap_metadata_candidates( - file_ids, - hashes, - requested_limit=limit, - freeform_mode=freeform_union_search, - ) - - if file_ids: - metadata_list = _fetch_search_metadata( - file_ids=file_ids, - include_tags=True, - include_urls=True, - include_mime=True, - ) - elif hashes: - metadata_list = _fetch_search_metadata( - hashes=hashes, - include_tags=True, - include_urls=True, - include_mime=True, - ) - else: - metadata_list = [] - - # If our free-text searches produce nothing (or nothing survived downstream filtering), fallback to scanning. - if (not metadata_list) and (query_lower - != "*") and (":" not in query_lower): - try: - search_result = client.search_files( - tags=["system:everything"], - return_hashes=False, - return_file_ids=True, - ) - file_ids, _ = _extract_search_ids(search_result) - hashes = [] - - file_ids, hashes = _cap_metadata_candidates( - file_ids, - hashes, - requested_limit=limit, - freeform_mode=True, - fallback_scan=True, - ) - - if file_ids: - metadata_list = _fetch_search_metadata( - file_ids=file_ids, - include_tags=True, - include_urls=True, - include_mime=True, - ) - elif hashes: - metadata_list = _fetch_search_metadata( - hashes=hashes, - include_tags=True, - include_urls=True, - include_mime=True, - ) - except Exception: - pass - - if not isinstance(metadata_list, list): - metadata_list = [] - - for meta in metadata_list: - if len(results) >= limit: - break - - file_id = meta.get("file_id") - hash_hex = meta.get("hash") - size_val = meta.get("size") - if size_val is None: - size_val = meta.get("size_bytes") - try: - size = int(size_val) if size_val is not None else 0 - except Exception: - size = 0 - - title, all_tags = self._extract_title_and_tags(meta, file_id) - - # Prefer Hydrus-provided extension (e.g. ".webm"); fall back to MIME map. - mime_type = meta.get("mime") - ext = _resolve_ext_from_meta(meta, mime_type) - - # Filter results based on query type - # If user provided explicit namespace (has ':'), don't do substring filtering - # Just include what the tag search returned - has_namespace = ":" in query_lower - - # Use known URLs (source URLs) from Hydrus if available (matches get-url cmdlet) - item_url = meta.get("known_urls") or meta.get("urls") or meta.get("url") or [] - if not item_url: - item_url = meta.get("file_url") or f"{self.URL.rstrip('/')}/view_file?hash={hash_hex}" - if isinstance(item_url, str) and "/view_file" in item_url: - item_url = self._append_access_key(item_url) - - if has_namespace: - # Explicit namespace search - already filtered by Hydrus tag search - # Include this result as-is - results.append( - { - "hash": hash_hex, - "url": item_url, - "name": title, - "title": title, - "size": size, - "size_bytes": size, - "store": self.NAME, - "tag": all_tags, - "file_id": file_id, - "mime": mime_type, - "ext": ext, - } - ) - else: - # Free-form search: check if search terms match title or FREEFORM tags. - # Do NOT implicitly match other namespace tags (except title:). - freeform_tags = [ - t for t in all_tags - if isinstance(t, str) and t and (":" not in t) - ] - searchable_text = (title + " " + " ".join(freeform_tags)).lower() - - match = True - if query_lower != "*" and search_terms: - for term in search_terms: - if term not in searchable_text: - match = False - break - - if match: - results.append( - { - "hash": hash_hex, - "url": item_url, - "name": title, - "title": title, - "size": size, - "size_bytes": size, - "store": self.NAME, - "tag": all_tags, - "file_id": file_id, - "mime": mime_type, - "ext": ext, - } - ) - if ext_filter: - wanted = ext_filter - filtered: list[dict[str, Any]] = [] - for item in results: - try: - if _normalize_ext_filter(str(item.get("ext") or "")) == wanted: - filtered.append(item) - except Exception: - continue - results = filtered - - return results[:limit] - - except Exception as exc: - log(f"❌ Hydrus search failed: {exc}", file=sys.stderr) - import traceback - - traceback.print_exc(file=sys.stderr) - raise - - def get_file(self, file_hash: str, **kwargs: Any) -> Path | str | None: - """Return the local file system path if available, else a browser URL. - - IMPORTANT: this method must be side-effect free (do not auto-open a browser). - Only explicit user actions (e.g. the get-file cmdlet) should open files. - """ - file_hash = str(file_hash or "").strip().lower() - try: - debug_panel( - "Hydrus get_file", - [ - ("hash", file_hash), - ("prefer_url", bool(kwargs.get("url"))), - ], - border_style="blue", - ) - except Exception: - pass - - # If 'url=True' is passed, we preference the browser URL even if a local path is available. - # This is typically used by the 'get-file' cmdlet for interactive viewing. - if kwargs.get("url"): - base_url = str(self.URL).rstrip("/") - access_key = str(self.API) - browser_url = ( - f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}" - ) - try: - debug_panel( - "Hydrus get_file", - [ - ("mode", "browser-url"), - ("url", browser_url), - ], - border_style="blue", - ) - except Exception: - pass - return browser_url - - # Try to get the local disk path if possible (works if Hydrus is on same machine) - server_path = None - try: - path_res = self._client.get_file_path(file_hash) - if isinstance(path_res, dict) and "path" in path_res: - server_path = path_res["path"] - if server_path: - local_path = Path(server_path) - if local_path.exists(): - try: - debug_panel( - "Hydrus get_file", - [ - ("mode", "local-path"), - ("path", local_path), - ], - border_style="green", - ) - except Exception: - pass - return local_path - except Exception as e: - try: - debug_panel( - "Hydrus get_file", - [ - ("mode", "path-lookup-error"), - ("error", e), - ], - border_style="yellow", - ) - except Exception: - pass - - # If we found a path on the server but it's not locally accessible, - # keep it for logging but continue to the browser URL fallback so the UI - # can still open the file via the Hydrus web UI. - if server_path: - try: - debug_panel( - "Hydrus get_file fallback", - [ - ("mode", "remote-http"), - ("server_path", server_path), - ], - border_style="yellow", - ) - except Exception: - pass - - # Fallback to browser URL with access key - base_url = str(self.URL).rstrip("/") - access_key = str(self.API) - browser_url = ( - f"{base_url}/get_files/file?hash={file_hash}&Hydrus-Client-API-Access-Key={access_key}" - ) - try: - debug_panel( - "Hydrus get_file fallback", - [ - ("mode", "remote-http"), - ("url", browser_url), - ], - border_style="yellow", - ) - except Exception: - pass - return browser_url - - def download_to_temp( - self, - file_hash: str, - *, - temp_root: Optional[Path] = None, - ) -> Optional[Path]: - """Download a Hydrus file to a temporary path for downstream uploads.""" - - try: - client = self._client - if client is None: - return None - - h = str(file_hash or "").strip().lower() - if len(h) != 64 or not all(ch in "0123456789abcdef" for ch in h): - return None - - created_tmp = False - base_tmp = Path(temp_root) if temp_root is not None else Path( - tempfile.mkdtemp(prefix="hydrus-file-") - ) - if temp_root is None: - created_tmp = True - base_tmp.mkdir(parents=True, exist_ok=True) - - def _safe_filename(raw: str) -> str: - cleaned = re.sub(r"[\\/:*?\"<>|]", "_", str(raw or "")).strip() - if not cleaned: - return h - cleaned = cleaned.strip(". ") or h - return cleaned - - # Prefer ext/title from metadata when available. - fname = h - ext_val = "" - try: - meta = self.get_metadata(h) or {} - if isinstance(meta, dict): - title_val = str(meta.get("title") or "").strip() - if title_val: - fname = _safe_filename(title_val) - ext_val = str(meta.get("ext") or "").strip().lstrip(".") - except Exception: - pass - - if not fname: - fname = h - if ext_val and not fname.lower().endswith(f".{ext_val.lower()}"): - fname = f"{fname}.{ext_val}" - - try: - file_url = client.file_url(h) - except Exception: - file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={quote(h)}" - - dest_path = base_tmp / fname - stream_client = get_shared_httpx_client(timeout=60.0, verify_ssl=False) - with stream_client.stream( - "GET", - file_url, - headers={"Hydrus-Client-API-Access-Key": self.API}, - follow_redirects=True, - timeout=60.0, - ) as resp: - resp.raise_for_status() - with dest_path.open("wb") as fh: - for chunk in resp.iter_bytes(): - if chunk: - fh.write(chunk) - - if dest_path.exists(): - return dest_path - - if created_tmp: - try: - shutil.rmtree(base_tmp, ignore_errors=True) - except Exception: - pass - return None - except Exception as exc: - log(f"{self._log_prefix()} download_to_temp failed: {exc}", file=sys.stderr) - try: - if temp_root is None and "base_tmp" in locals(): - shutil.rmtree(base_tmp, ignore_errors=True) # type: ignore[arg-type] - except Exception: - pass - return None - - def delete_file(self, file_identifier: str, **kwargs: Any) -> bool: - """Delete a file from Hydrus, then clear the deletion record. - - This is used by the delete-file cmdlet when the item belongs to a HydrusNetwork store. - """ - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} delete_file: client unavailable") - return False - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - debug( - f"{self._log_prefix()} delete_file: invalid file hash '{file_identifier}'" - ) - return False - - reason = kwargs.get("reason") - reason_text = ( - str(reason).strip() if isinstance(reason, - str) and reason.strip() else None - ) - - # 1) Delete file - client.delete_files([file_hash], reason=reason_text) - - # 2) Clear deletion record (best-effort) - try: - client.clear_file_deletion_record([file_hash]) - except Exception as exc: - debug( - f"{self._log_prefix()} delete_file: clear_file_deletion_record failed: {exc}" - ) - - return True - except Exception as exc: - debug(f"{self._log_prefix()} delete_file failed: {exc}") - return False - - def build_file_url(self, file_hash: str, *, include_access_key: bool = True) -> str: - normalized = str(file_hash or "").strip().lower() - base_url = str(self.URL).rstrip("/") - url = f"{base_url}/get_files/file?hash={quote(normalized)}" - if include_access_key and str(self.API or "").strip(): - url = f"{url}&Hydrus-Client-API-Access-Key={quote(str(self.API))}" - return url - - def fetch_file_metadata(self, file_hash: str, **kwargs: Any) -> Optional[Dict[str, Any]]: - try: - client = self._client - if client is None: - return None - return client.fetch_file_metadata(hashes=[str(file_hash or "").strip().lower()], **kwargs) - except Exception: - return None - - def get_relationships(self, file_hash: str) -> Optional[Dict[str, Any]]: - try: - client = self._client - if client is None: - return None - payload = client.get_file_relationships(str(file_hash or "").strip().lower()) - return payload if isinstance(payload, dict) else None - except Exception: - return None - - def get_metadata(self, file_hash: str, **kwargs: Any) -> Optional[Dict[str, Any]]: - """Get metadata for a file from Hydrus by hash. - - Args: - file_hash: SHA256 hash of the file (64-char hex string) - - Returns: - Dict with metadata fields or None if not found - """ - try: - client = self._client - if not client: - debug(f"{self._log_prefix()} get_metadata: client unavailable") - return None - - # Fetch file metadata with the fields we need for CLI display. - payload = client.fetch_file_metadata( - hashes=[file_hash], - include_service_keys_to_tags=True, - include_file_url=True, - include_duration=True, - include_size=True, - include_mime=True, - ) - - if not payload or not payload.get("metadata"): - return None - - meta = payload["metadata"][0] - - # Hydrus can return placeholder metadata rows for unknown hashes. - if not isinstance(meta, dict) or meta.get("file_id") is None: - return None - - # Extract title from tags - title = f"Hydrus_{file_hash[:12]}" - extracted_tags = self._extract_tags_from_hydrus_meta( - meta, - service_key=None, - service_name=None, - ) - for raw_tag in extracted_tags: - tag_text = str(raw_tag or "").strip() - if not tag_text: - continue - if tag_text.lower().startswith("title:"): - value = tag_text.split(":", 1)[1].strip() - if value: - title = value - break - - # Hydrus may return mime as an int enum, or sometimes a human label. - mime_val = meta.get("mime") - filetype_human = ( - meta.get("filetype_human") or meta.get("mime_human") - or meta.get("mime_string") - ) - - # Determine ext: prefer Hydrus metadata ext, then filetype_human (when it looks like an ext), - # then title suffix, then file path suffix. - ext = str(meta.get("ext") or "").strip().lstrip(".") - if not ext: - ft = str(filetype_human or "").strip().lstrip(".").lower() - if ft and ft != "unknown filetype" and ft.isalnum() and len(ft) <= 8: - # Treat simple labels like "mp4", "m4a", "webm" as extensions. - ext = ft - if not ext and isinstance(title, str) and "." in title: - try: - ext = Path(title).suffix.lstrip(".") - except Exception: - ext = "" - if not ext: - try: - path_payload = client.get_file_path(file_hash) - if isinstance(path_payload, dict): - p = path_payload.get("path") - if isinstance(p, str) and p.strip(): - ext = Path(p.strip()).suffix.lstrip(".") - except Exception: - ext = "" - - # If extension is still unknown, attempt a best-effort lookup from MIME. - def _mime_from_ext(ext_value: str) -> str: - ext_clean = str(ext_value or "").strip().lstrip(".").lower() - if not ext_clean: - return "" - try: - for category in mime_maps.values(): - info = category.get(ext_clean) - if isinstance(info, dict): - mimes = info.get("mimes") - if isinstance(mimes, list) and mimes: - first = mimes[0] - return str(first) - except Exception: - return "" - return "" - - # Normalize to a MIME string for CLI output. - # Avoid passing through human labels like "unknown filetype". - mime_type = "" - if isinstance(mime_val, str): - candidate = mime_val.strip() - if "/" in candidate and candidate.lower() != "unknown filetype": - mime_type = candidate - if not mime_type and isinstance(filetype_human, str): - candidate = filetype_human.strip() - if "/" in candidate and candidate.lower() != "unknown filetype": - mime_type = candidate - if not mime_type: - mime_type = _mime_from_ext(ext) - - # Normalize size/duration to stable scalar types. - size_val = meta.get("size") - if size_val is None: - size_val = meta.get("size_bytes") - try: - size_int: int | None = int(size_val) if size_val is not None else 0 - except Exception: - size_int = 0 - - dur_val = meta.get("duration") - if dur_val is None: - dur_val = meta.get("duration_ms") - try: - dur_int: int | None = int(dur_val) if dur_val is not None else None - except Exception: - dur_int = None - - raw_urls = meta.get("known_urls") or meta.get("urls") or meta.get("url" - ) or [] - url_list: list[str] = [] - if isinstance(raw_urls, str): - s = raw_urls.strip() - url_list = [s] if s else [] - elif isinstance(raw_urls, list): - url_list = [ - str(u).strip() for u in raw_urls - if isinstance(u, str) and str(u).strip() - ] - - return { - "hash": file_hash, - "title": title, - "ext": ext, - "size": size_int, - "mime": mime_type, - # Keep raw fields available for troubleshooting/other callers. - "hydrus_mime": mime_val, - "filetype_human": filetype_human, - "duration_ms": dur_int, - "url": url_list, - } - - except Exception as exc: - debug(f"{self._log_prefix()} get_metadata failed: {exc}") - return None - - def get_tag(self, file_identifier: str, **kwargs: Any) -> Tuple[List[str], str]: - """Get tags for a file from Hydrus by hash. - - Args: - file_identifier: File hash (SHA256 hex string) - **kwargs: Optional service_name parameter - - Returns: - Tuple of (tags_list, source_description) - where source is always "hydrus" - """ - try: - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - debug( - f"{self._log_prefix()} get_tags: invalid file hash '{file_identifier}'" - ) - return [], "unknown" - - # Get Hydrus client and service info - client = self._client - if not client: - debug(f"{self._log_prefix()} get_tags: client unavailable") - return [], "unknown" - - # Fetch file metadata - payload = client.fetch_file_metadata( - hashes=[file_hash], - include_service_keys_to_tags=True, - include_file_url=True - ) - - items = payload.get("metadata") if isinstance(payload, dict) else None - if not isinstance(items, list) or not items: - debug( - f"{self._log_prefix()} get_tags: no metadata for hash {file_hash}" - ) - return [], "unknown" - - meta = items[0] if isinstance(items[0], dict) else None - if not isinstance(meta, dict) or meta.get("file_id") is None: - debug( - f"{self._log_prefix()} get_tags: invalid metadata for hash {file_hash}" - ) - return [], "unknown" - - service_name = kwargs.get("service_name") or "my tags" - service_key = self._get_service_key(service_name) - - # Extract tags from metadata - tags = self._extract_tags_from_hydrus_meta(meta, service_key, service_name) - - return [ - str(t).strip().lower() for t in tags if isinstance(t, str) and t.strip() - ], "hydrus" - - except Exception as exc: - debug(f"{self._log_prefix()} get_tags failed: {exc}") - return [], "unknown" - - def add_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool: - """Add tags to a Hydrus file.""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} add_tag: client unavailable") - return False - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - debug( - f"{self._log_prefix()} add_tag: invalid file hash '{file_identifier}'" - ) - return False - service_name = kwargs.get("service_name") or "my tags" - - incoming_tags = [ - str(t).strip().lower() for t in (tags or []) - if isinstance(t, str) and str(t).strip() - ] - if not incoming_tags: - return True - - existing_tags = kwargs.get("existing_tags") - if existing_tags is None: - try: - existing_tags, _src = self.get_tag(file_hash) - except Exception: - existing_tags = [] - if isinstance(existing_tags, (list, tuple, set)): - existing_tags = [ - str(t).strip().lower() for t in existing_tags - if isinstance(t, str) and str(t).strip() - ] - else: - existing_tags = [] - - from SYS.metadata import compute_namespaced_tag_overwrite - - tags_to_remove, tags_to_add, _merged = compute_namespaced_tag_overwrite( - existing_tags, incoming_tags - ) - - if not tags_to_add and not tags_to_remove: - return True - - service_key: Optional[str] = None - service_key = self._get_service_key(service_name) - - mutate_success = False - if service_key: - try: - client.mutate_tags_by_key( - file_hash, - service_key, - add_tags=tags_to_add, - remove_tags=tags_to_remove, - ) - mutate_success = True - except Exception as exc: - debug( - f"{self._log_prefix()} add_tag: mutate_tags_by_key failed: {exc}" - ) - - did_any = False - if not mutate_success: - if tags_to_remove: - try: - client.delete_tag(file_hash, tags_to_remove, service_name) - did_any = True - except Exception as exc: - debug( - f"{self._log_prefix()} add_tag: delete_tag failed: {exc}" - ) - if tags_to_add: - try: - client.add_tag(file_hash, tags_to_add, service_name) - did_any = True - except Exception as exc: - debug( - f"{self._log_prefix()} add_tag: add_tag failed: {exc}" - ) - else: - did_any = bool(tags_to_add or tags_to_remove) - - return did_any - except Exception as exc: - debug(f"{self._log_prefix()} add_tag failed: {exc}") - return False - - def delete_tag(self, file_identifier: str, tags: List[str], **kwargs: Any) -> bool: - """Delete tags from a Hydrus file.""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} delete_tag: client unavailable") - return False - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - debug( - f"{self._log_prefix()} delete_tag: invalid file hash '{file_identifier}'" - ) - return False - service_name = kwargs.get("service_name") or "my tags" - raw_list = list(tags) if isinstance(tags, (list, tuple)) else [str(tags)] - tag_list = [ - str(t).strip().lower() for t in raw_list - if isinstance(t, str) and str(t).strip() - ] - if not tag_list: - return False - client.delete_tag(file_hash, tag_list, service_name) - return True - except Exception as exc: - debug(f"{self._log_prefix()} delete_tag failed: {exc}") - return False - - def get_url(self, file_identifier: str, **kwargs: Any) -> List[str]: - """Get known url for a Hydrus file.""" - try: - client = self._client - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - return [] - - payload = client.fetch_file_metadata( - hashes=[file_hash], - include_file_url=True - ) - items = payload.get("metadata") if isinstance(payload, dict) else None - if not isinstance(items, list) or not items: - return [] - meta = items[0] if isinstance(items[0], - dict) else {} - - raw_urls: Any = meta.get("known_urls" - ) or meta.get("urls") or meta.get("url") or [] - - def _is_url(s: Any) -> bool: - if not isinstance(s, str): - return False - v = s.strip().lower() - return bool(v and ("://" in v or v.startswith(("magnet:", "torrent:")))) - - if isinstance(raw_urls, str): - val = raw_urls.strip() - return [val] if _is_url(val) else [] - if isinstance(raw_urls, list): - out: list[str] = [] - for u in raw_urls: - if not isinstance(u, str): - continue - u = u.strip() - if u and _is_url(u): - out.append(u) - return out - return [] - except Exception as exc: - debug(f"{self._log_prefix()} get_url failed: {exc}") - return [] - - def lookup_url_metadata(self, url_value: str, *, minimal: bool = False) -> List[Dict[str, Any]]: - """Resolve an exact URL to Hydrus metadata using /add_urls/get_url_files variants.""" - candidate_url = str(url_value or "").strip() - if not candidate_url: - return [] - - client = self._client - if client is None: - return [] - - try: - from plugins.hydrusnetwork.api import HydrusRequestSpec, _generate_hydrus_url_variants - except Exception: - return [] - - pending: deque[str] = deque(_generate_hydrus_url_variants(candidate_url) or [candidate_url]) - seen_urls: set[str] = set() - file_ids: List[int] = [] - hashes: List[str] = [] - seen_ids: set[int] = set() - seen_hashes: set[str] = set() - - while pending: - current = str(pending.popleft() or "").strip() - if not current or current in seen_urls: - continue - seen_urls.add(current) - - try: - response = client._perform_request( - HydrusRequestSpec( - method="GET", - endpoint="/add_urls/get_url_files", - query={"url": current}, - ) - ) - except Exception: - continue - - if not isinstance(response, dict): - continue - - raw_hashes = response.get("hashes") or response.get("file_hashes") - if isinstance(raw_hashes, list): - for item in raw_hashes: - try: - file_hash = str(item or "").strip().lower() - except Exception: - continue - if not file_hash or file_hash in seen_hashes: - continue - seen_hashes.add(file_hash) - hashes.append(file_hash) - - raw_ids = response.get("file_ids") or response.get("file_id") - id_values = raw_ids if isinstance(raw_ids, list) else [raw_ids] if raw_ids is not None else [] - for item in id_values: - try: - file_id = int(item) - except (TypeError, ValueError): - continue - if file_id in seen_ids: - continue - seen_ids.add(file_id) - file_ids.append(file_id) - - statuses = response.get("url_file_statuses") - if isinstance(statuses, list): - for entry in statuses: - if not isinstance(entry, dict): - continue - - status_hash = entry.get("hash") or entry.get("file_hash") - try: - normalized_hash = str(status_hash or "").strip().lower() - except Exception: - normalized_hash = "" - if normalized_hash and normalized_hash not in seen_hashes: - seen_hashes.add(normalized_hash) - hashes.append(normalized_hash) - - status_id = entry.get("file_id") or entry.get("fileid") - try: - file_id = int(status_id) if status_id is not None else None - except (TypeError, ValueError): - file_id = None - if file_id is None or file_id in seen_ids: - continue - seen_ids.add(file_id) - file_ids.append(file_id) - - for key in ("normalized_url", "redirect_url", "url"): - value = response.get(key) - if isinstance(value, str): - next_url = value.strip() - if next_url and next_url not in seen_urls: - pending.append(next_url) - - if not file_ids and not hashes: - return [] - - try: - payload = client.fetch_file_metadata( - file_ids=file_ids or None, - hashes=hashes or None, - include_file_url=True, - include_service_keys_to_tags=not minimal, - include_duration=not minimal, - include_size=not minimal, - include_mime=not minimal, - ) - except Exception: - return [] - - metadata = payload.get("metadata") if isinstance(payload, dict) else None - if not isinstance(metadata, list): - return [] - return [entry for entry in metadata if isinstance(entry, dict)] - - def find_hashes_by_url(self, url_value: str) -> List[str]: - hashes: List[str] = [] - seen: set[str] = set() - for entry in self.lookup_url_metadata(url_value, minimal=True): - raw_hash = entry.get("hash") or entry.get("hash_hex") or entry.get("file_hash") - try: - file_hash = str(raw_hash or "").strip().lower() - except Exception: - continue - if len(file_hash) != 64 or file_hash in seen: - continue - seen.add(file_hash) - hashes.append(file_hash) - return hashes - - def get_url_info(self, url: str, **kwargs: Any) -> dict[str, Any] | None: - """Return Hydrus URL info for a single URL (Hydrus-only helper). - - Uses: GET /add_urls/get_url_info - """ - try: - client = self._client - if client is None: - return None - u = str(url or "").strip() - if not u: - return None - try: - return client.get_url_info(u) # type: ignore[attr-defined] - except Exception: - from plugins.hydrusnetwork.api import HydrusRequestSpec - - spec = HydrusRequestSpec( - method="GET", - endpoint="/add_urls/get_url_info", - query={ - "url": u - }, - ) - response = client._perform_request(spec) # type: ignore[attr-defined] - return response if isinstance(response, dict) else None - except Exception as exc: - debug(f"{self._log_prefix()} get_url_info failed: {exc}") - return None - - def add_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool: - """Associate one or more url with a Hydrus file.""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} add_url: client unavailable") - return False - for u in url: - client.associate_url(file_identifier, u) - return True - except Exception as exc: - debug(f"{self._log_prefix()} add_url failed: {exc}") - return False - - def add_url_bulk(self, items: List[tuple[str, List[str]]], **kwargs: Any) -> bool: - """Bulk associate urls with Hydrus files. - - This is a best-effort convenience wrapper used by cmdlets to batch url associations. - Hydrus' client API is still called per (hash,url) pair, but this consolidates the - cmdlet-level control flow so url association can be deferred until the end. - """ - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} add_url_bulk: client unavailable") - return False - - any_success = False - for file_identifier, urls in items or []: - h = str(file_identifier or "").strip().lower() - if len(h) != 64: - continue - for u in urls or []: - s = str(u or "").strip() - if not s: - continue - try: - client.associate_url(h, s) - any_success = True - except Exception: - continue - return any_success - except Exception as exc: - debug(f"{self._log_prefix()} add_url_bulk failed: {exc}") - return False - - def add_tags_bulk(self, items: List[tuple[str, List[str]]], *, service_name: str | None = None) -> bool: - """Bulk add tags to multiple Hydrus files. - - Groups files by identical tag-sets and uses the Hydrus `mutate_tags_by_key` - call (when a service key is available) to reduce the number of API calls. - Falls back to per-hash `add_tag` calls if necessary. - """ - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} add_tags_bulk: client unavailable") - return False - - # Group by canonical tag set (sorted tuple) to batch identical additions - buckets: dict[tuple[str, ...], list[str]] = {} - for file_identifier, tags in items or []: - h = str(file_identifier or "").strip().lower() - if len(h) != 64: - continue - tlist = [str(t).strip().lower() for t in (tags or []) if isinstance(t, str) and str(t).strip()] - if not tlist: - continue - key = tuple(sorted(tlist)) - buckets.setdefault(key, []).append(h) - - if not buckets: - return False - - svc = service_name or "my tags" - service_key = self._get_service_key(svc) - any_success = False - - for tag_tuple, hashes in buckets.items(): - try: - if service_key: - # Mutate tags for many hashes in a single request - client.mutate_tags_by_key(hash=hashes, service_key=service_key, add_tags=list(tag_tuple)) - any_success = True - continue - except Exception as exc: - debug(f"{self._log_prefix()} add_tags_bulk mutate failed for tags {tag_tuple}: {exc}") - - # Fallback: apply per-hash add_tag - for h in hashes: - try: - client.add_tag(h, list(tag_tuple), svc) - any_success = True - except Exception: - continue - - return any_success - except Exception as exc: - debug(f"{self._log_prefix()} add_tags_bulk failed: {exc}") - return False - - def delete_url(self, file_identifier: str, url: List[str], **kwargs: Any) -> bool: - """Delete one or more url from a Hydrus file.""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} delete_url: client unavailable") - return False - for u in url: - client.delete_url(file_identifier, u) - return True - except Exception as exc: - debug(f"{self._log_prefix()} delete_url failed: {exc}") - return False - - def get_note(self, file_identifier: str, **kwargs: Any) -> Dict[str, str]: - """Get notes for a Hydrus file (default note service only).""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} get_note: client unavailable") - return {} - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - return {} - - payload = client.fetch_file_metadata(hashes=[file_hash], include_notes=True) - items = payload.get("metadata") if isinstance(payload, dict) else None - if not isinstance(items, list) or not items: - return {} - meta = items[0] if isinstance(items[0], dict) else None - if not isinstance(meta, dict): - return {} - - notes_payload = meta.get("notes") - if isinstance(notes_payload, dict): - return { - str(k): str(v or "") - for k, v in notes_payload.items() if str(k).strip() - } - - return {} - except Exception as exc: - debug(f"{self._log_prefix()} get_note failed: {exc}") - return {} - - def set_note( - self, - file_identifier: str, - name: str, - text: str, - **kwargs: Any - ) -> bool: - """Set a named note for a Hydrus file (default note service only).""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} set_note: client unavailable") - return False - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - return False - - note_name = str(name or "").strip() - if not note_name: - return False - note_text = str(text or "") - - client.set_notes(file_hash, - { - note_name: note_text - }) - return True - except Exception as exc: - debug(f"{self._log_prefix()} set_note failed: {exc}") - return False - - def delete_note(self, file_identifier: str, name: str, **kwargs: Any) -> bool: - """Delete a named note for a Hydrus file (default note service only).""" - try: - client = self._client - if client is None: - debug(f"{self._log_prefix()} delete_note: client unavailable") - return False - - file_hash = str(file_identifier or "").strip().lower() - if len(file_hash) != 64 or not all(ch in "0123456789abcdef" - for ch in file_hash): - return False - - note_name = str(name or "").strip() - if not note_name: - return False - - client.delete_notes(file_hash, [note_name]) - return True - except Exception as exc: - debug(f"{self._log_prefix()} delete_note failed: {exc}") - return False - - @staticmethod - def _extract_tags_from_hydrus_meta( - meta: Dict[str, - Any], - service_key: Optional[str], - service_name: Optional[str] - ) -> List[str]: - """Extract current tags from Hydrus metadata dict. - - Prefers display_tags (includes siblings/parents, excludes deleted). - Falls back to storage_tags status '0' (current). - """ - tags_payload = meta.get("tags") - if not isinstance(tags_payload, Mapping): - return [] - - desired_service_name = str(service_name or "").strip().lower() - desired_service_key = str(service_key).strip() if service_key is not None else "" - - def _append_tag(out: List[str], value: Any) -> None: - text = "" - if isinstance(value, bytes): - try: - text = value.decode("utf-8", errors="ignore") - except Exception: - text = str(value) - elif isinstance(value, str): - text = value - if not text: - return - cleaned = text.strip() - if cleaned: - out.append(cleaned) - - def _collect_current(container: Any, out: List[str]) -> None: - if isinstance(container, SequenceABC) and not isinstance(container, (str, bytes, bytearray, Mapping)): - for tag in container: - _append_tag(out, tag) - return - if isinstance(container, Mapping): - current = container.get("0") - if current is None: - current = container.get(0) - if isinstance(current, SequenceABC) and not isinstance(current, (str, bytes, bytearray, Mapping)): - for tag in current: - _append_tag(out, tag) - - def _collect_service_data(service_data: Any, out: List[str]) -> None: - if not isinstance(service_data, Mapping): - return - - display = ( - service_data.get("display_tags") - or service_data.get("display_friendly_tags") - or service_data.get("display") - ) - _collect_current(display, out) - - storage = ( - service_data.get("storage_tags") - or service_data.get("statuses_to_tags") - or service_data.get("tags") - ) - _collect_current(storage, out) - - collected: List[str] = [] - - if desired_service_key: - _collect_service_data(tags_payload.get(desired_service_key), collected) - - if not collected and desired_service_name: - for maybe_service in tags_payload.values(): - if not isinstance(maybe_service, Mapping): - continue - svc_name = str( - maybe_service.get("service_name") - or maybe_service.get("name") - or "" - ).strip().lower() - if svc_name and svc_name == desired_service_name: - _collect_service_data(maybe_service, collected) - - names_map = tags_payload.get("service_keys_to_names") - statuses_map = tags_payload.get("service_keys_to_statuses_to_tags") - if isinstance(statuses_map, Mapping): - keys_to_collect: List[str] = [] - if desired_service_key: - keys_to_collect.append(desired_service_key) - if desired_service_name and isinstance(names_map, Mapping): - for raw_key, raw_name in names_map.items(): - if str(raw_name or "").strip().lower() == desired_service_name: - keys_to_collect.append(str(raw_key)) - keys_filter = {k for k in keys_to_collect if k} - - for raw_key, status_payload in statuses_map.items(): - raw_key_text = str(raw_key) - if keys_filter and raw_key_text not in keys_filter: - continue - _collect_current(status_payload, collected) - - if not collected: - for maybe_service in tags_payload.values(): - _collect_service_data(maybe_service, collected) - - top_level_tags = meta.get("tags_flat") - if isinstance(top_level_tags, SequenceABC) and not isinstance(top_level_tags, (str, bytes, bytearray, Mapping)): - _collect_current(top_level_tags, collected) - - deduped: List[str] = [] - seen: set[str] = set() - for tag in collected: - key = str(tag).strip().lower() - if not key or key in seen: - continue - seen.add(key) - deduped.append(tag) - return deduped - - @staticmethod - def _extract_title_and_tags(meta: Dict[str, Any], file_id: Any) -> Tuple[str, List[str]]: - title = f"Hydrus File {file_id}" - tags = HydrusNetwork._extract_tags_from_hydrus_meta( - meta, - service_key=None, - service_name=None, - ) - - normalized_tags: List[str] = [] - seen: set[str] = set() - for raw_tag in tags: - text = str(raw_tag or "").strip().lower() - if not text or text in seen: - continue - seen.add(text) - normalized_tags.append(text) - if text.startswith("title:") and title == f"Hydrus File {file_id}": - value = text.split(":", 1)[1].strip() - if value: - title = value - - return title, normalized_tags diff --git a/Store/registry.py b/Store/registry.py index 81748c8..dd119d7 100644 --- a/Store/registry.py +++ b/Store/registry.py @@ -25,6 +25,7 @@ from Store._base import Store as BaseStore _SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$") _DISCOVERED_CLASSES_CACHE: Optional[Dict[str, Type[BaseStore]]] = None +_PLUGIN_DISCOVERED_CLASSES_CACHE: Dict[str, Optional[Type[BaseStore]]] = {} # Backends that failed to initialize earlier in the current process. # Keyed by (store_type, instance_key) where instance_key is the name used under config.store... @@ -85,6 +86,101 @@ def _discover_store_classes() -> Dict[str, Type[BaseStore]]: return discovered +def _extract_store_classes(owner: Any) -> Dict[str, Type[BaseStore]]: + discovered: Dict[str, Type[BaseStore]] = {} + + def _add_candidate(key: Any, candidate: Any) -> None: + if not inspect.isclass(candidate): + return + if candidate is BaseStore: + return + if not issubclass(candidate, BaseStore): + return + normalized = _normalize_store_type(str(key or candidate.__name__)) + if normalized: + discovered[normalized] = candidate + + if owner is None: + return discovered + + if inspect.isclass(owner): + _add_candidate(None, owner) + return discovered + + if isinstance(owner, dict): + for key, candidate in owner.items(): + _add_candidate(key, candidate) + return discovered + + if isinstance(owner, (list, tuple, set, frozenset)): + for candidate in owner: + _add_candidate(None, candidate) + return discovered + + try: + for key, candidate in vars(owner).items(): + _add_candidate(key, candidate) + except Exception: + pass + return discovered + + +def _discover_plugin_store_class(store_type: str) -> Optional[Type[BaseStore]]: + normalized = _normalize_store_type(store_type) + if not normalized: + return None + + cached = _PLUGIN_DISCOVERED_CLASSES_CACHE.get(normalized, None) + if normalized in _PLUGIN_DISCOVERED_CLASSES_CACHE: + return cached + + try: + plugin_module = importlib.import_module(f"plugins.{normalized}") + except Exception: + _PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = None + return None + + discovered: Dict[str, Type[BaseStore]] = {} + + backend_hook = getattr(plugin_module, "get_store_backend_classes", None) + if callable(backend_hook): + try: + discovered.update(_extract_store_classes(backend_hook())) + except Exception as exc: + debug(f"[Store] Failed to load plugin store backends for '{normalized}': {exc}") + + discovered.update(_extract_store_classes(getattr(plugin_module, "STORE_BACKENDS", None))) + + if normalized not in discovered: + discovered.update(_extract_store_classes(plugin_module)) + + resolved = discovered.get(normalized) + if resolved is None and len(discovered) == 1: + resolved = next(iter(discovered.values())) + + _PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = resolved + return resolved + + +def _resolve_store_class( + store_type: str, + classes_by_type: Optional[Dict[str, Type[BaseStore]]] = None, +) -> Optional[Type[BaseStore]]: + normalized = _normalize_store_type(store_type) + if not normalized: + return None + + plugin_resolved = _discover_plugin_store_class(normalized) + if plugin_resolved is not None: + return plugin_resolved + + discovered = classes_by_type if classes_by_type is not None else _discover_store_classes() + resolved = discovered.get(normalized) + if resolved is not None: + return resolved + return None + + def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]: # Support new config_schema() schema if hasattr(store_cls, "config_schema") and callable(store_cls.config_schema): @@ -170,7 +266,7 @@ class Store: store_type = _normalize_store_type(str(raw_store_type)) if store_type == "folder": continue - store_cls = classes_by_type.get(store_type) + store_cls = _resolve_store_class(store_type, classes_by_type) if store_cls is None: # Skip provider-only names without debug warning if store_type not in _PROVIDER_ONLY_STORE_NAMES and not self._suppress_debug: @@ -373,7 +469,7 @@ def list_configured_backend_names(config: Optional[Dict[str, Any]]) -> list[str] if store_type == "folder" or store_type in _PROVIDER_ONLY_STORE_NAMES: continue - store_cls = classes_by_type.get(store_type) + store_cls = _resolve_store_class(store_type, classes_by_type) if store_cls is None: continue @@ -417,7 +513,7 @@ def get_backend_instance(config: Optional[Dict[str, Any]], backend_name: str, *, if not isinstance(instances, dict): continue store_type = _normalize_store_type(str(raw_store_type)) - store_cls = classes_by_type.get(store_type) + store_cls = _resolve_store_class(store_type, classes_by_type) if store_cls is None: continue diff --git a/cmdlet/__init__.py b/cmdlet/__init__.py index cb38709..64e00dc 100644 --- a/cmdlet/__init__.py +++ b/cmdlet/__init__.py @@ -103,6 +103,17 @@ def _register_native_commands() -> None: pass +def _register_plugin_commands() -> None: + try: + from ProviderCore.commands import register_plugin_commands + except Exception: + return + try: + register_plugin_commands(REGISTRY) + except Exception: + pass + + def ensure_cmdlet_modules_loaded(force: bool = False) -> None: global _MODULES_LOADED @@ -115,4 +126,5 @@ def ensure_cmdlet_modules_loaded(force: bool = False) -> None: _load_root_modules() _load_helper_modules() _register_native_commands() + _register_plugin_commands() _MODULES_LOADED = True diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index b7d7f22..ea03f45 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -105,9 +105,13 @@ class CmdletArg: storage_flags = SharedArgs.STORAGE.to_flags() # Returns: ('--storage', '-storage', '-s') """ + normalized_name = str(self.name or "").lstrip("-") + if not normalized_name: + return tuple() + flags = [ - f"--{self.name}", - f"-{self.name}" + f"--{normalized_name}", + f"-{normalized_name}" ] # Both double-dash and single-dash variants # Add short form if alias exists @@ -116,8 +120,8 @@ class CmdletArg: # Add negation forms for flag type if self.type == "flag": - flags.append(f"--no-{self.name}") - flags.append(f"-no{self.name}") # Single-dash negation variant + flags.append(f"--no-{normalized_name}") + flags.append(f"-no{normalized_name}") # Single-dash negation variant if self.alias: flags.append(f"-n{self.alias}") @@ -1658,6 +1662,59 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]: List of normalized tag strings (empty strings filtered out) """ + def _split_top_level_commas(text: str) -> List[str]: + segments: List[str] = [] + current: List[str] = [] + paren_depth = 0 + angle_depth = 0 + quote: Optional[str] = None + escape = False + + for ch in text: + if escape: + current.append(ch) + escape = False + continue + if ch == "\\": + current.append(ch) + escape = True + continue + if quote: + current.append(ch) + if ch == quote: + quote = None + continue + if ch in {"'", '"'}: + current.append(ch) + quote = ch + continue + if ch == "(": + paren_depth += 1 + current.append(ch) + continue + if ch == ")": + paren_depth = max(0, paren_depth - 1) + current.append(ch) + continue + if ch == "<": + angle_depth += 1 + current.append(ch) + continue + if ch == ">": + angle_depth = max(0, angle_depth - 1) + current.append(ch) + continue + if ch == "," and paren_depth == 0 and angle_depth == 0: + segments.append("".join(current).strip()) + current = [] + continue + current.append(ch) + + tail = "".join(current).strip() + if tail or segments: + segments.append(tail) + return segments + def _expand_pipe_namespace(text: str) -> List[str]: parts = text.split("|") expanded: List[str] = [] @@ -1684,7 +1741,7 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]: tags: List[str] = [] for argument in arguments: - for token in argument.split(","): + for token in _split_top_level_commas(str(argument)): text = token.strip() if not text: continue @@ -1704,6 +1761,365 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]: return tags +_TAG_VALUE_TEMPLATE_RE = re.compile(r"#\(([^)]+)\)") +_TAG_VALUE_FUNCTION_RE = re.compile(r"<([a-zA-Z_][a-zA-Z0-9_-]*)\((.*?)\)>") + + +def _normalize_tag_value_template_name(value: Any) -> str: + text = str(value or "").strip().lower() + if not text: + return "" + try: + text = re.sub(r"\s+", " ", text).strip() + except Exception: + text = " ".join(text.split()) + return text + + +def _tag_value_template_keys(value: Any) -> list[str]: + normalized = _normalize_tag_value_template_name(value) + if not normalized: + return [] + + keys = [normalized] + + trimmed_hash = re.sub(r"\s*#+\s*$", "", normalized).strip() + if trimmed_hash and trimmed_hash not in keys: + keys.append(trimmed_hash) + + return keys + + +def _add_tag_values_to_lookup(lookup: Dict[str, List[str]], tag_text: Any) -> None: + text = str(tag_text or "").strip() + if not text or ":" not in text: + return + if _TAG_VALUE_TEMPLATE_RE.search(text) or _TAG_VALUE_FUNCTION_RE.search(text): + return + + namespace, value = text.split(":", 1) + value_text = str(value or "").strip() + if not value_text: + return + + for key in _tag_value_template_keys(namespace): + values = lookup.setdefault(key, []) + if value_text not in values: + values.append(value_text) + + +def build_tag_value_lookup( + tags: Optional[Iterable[Any]], + *, + result: Any = None, +) -> Dict[str, List[str]]: + """Build a placeholder lookup from existing tags and lightweight result fields. + + Placeholder lookups use ``#(namespace)`` syntax. Namespace matching is + case-insensitive and trims repeated whitespace. A trailing ``#`` in the + placeholder is ignored so inputs like ``#(track #)`` can resolve ``track:9``. + """ + + lookup: Dict[str, List[str]] = {} + for tag in tags or []: + _add_tag_values_to_lookup(lookup, tag) + + title_text = extract_title_from_result(result) + if title_text: + _add_tag_values_to_lookup(lookup, f"title:{title_text}") + + return lookup + + +def _split_tag_value_function_args(value: Any) -> list[str]: + text = str(value or "") + args: list[str] = [] + current: list[str] = [] + depth = 0 + quote: Optional[str] = None + escape = False + + for ch in text: + if escape: + current.append(ch) + escape = False + continue + if ch == "\\": + current.append(ch) + escape = True + continue + if quote: + current.append(ch) + if ch == quote: + quote = None + continue + if ch in {"'", '"'}: + current.append(ch) + quote = ch + continue + if ch in {"(", "[", "{"}: + depth += 1 + current.append(ch) + continue + if ch in {")", "]", "}"}: + depth = max(0, depth - 1) + current.append(ch) + continue + if ch == "," and depth == 0: + args.append("".join(current).strip()) + current = [] + continue + current.append(ch) + + tail = "".join(current).strip() + if tail or args: + args.append(tail) + return args + + +def _strip_tag_value_function_arg(value: Any) -> str: + text = str(value or "").strip() + if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}: + return text[1:-1] + return text + + +def _padding_width_from_spec(value: Any) -> Optional[int]: + spec = _strip_tag_value_function_arg(value) + if not spec: + return None + if re.fullmatch(r"0+", spec): + return len(spec) + if spec.isdigit(): + try: + width = int(spec) + except Exception: + return None + return width if width > 0 else None + return None + + +def _replace_tag_value_placeholders( + value: Any, + lookup: Dict[str, List[str]], + *, + preserve_unresolved: bool, +) -> tuple[str, bool]: + text = str(value or "") + unresolved = False + + def _replace(match: re.Match[str]) -> str: + nonlocal unresolved + keys = _tag_value_template_keys(match.group(1) or "") + values: List[str] = [] + for key in keys: + for candidate in lookup.get(key, []): + if candidate not in values: + values.append(candidate) + if not values: + unresolved = True + return match.group(0) if preserve_unresolved else "" + return ", ".join(values) + + return _TAG_VALUE_TEMPLATE_RE.sub(_replace, text), unresolved + + +def _coerce_tag_value_integer(value: Any) -> Optional[int]: + text = _strip_tag_value_function_arg(value) + if not text: + return None + if not re.fullmatch(r"[+-]?\d+", text): + return None + try: + return int(text) + except Exception: + return None + + +def _apply_tag_value_function( + name: str, + args: Sequence[str], + *, + lookup: Dict[str, List[str]], +) -> Optional[str]: + func = str(name or "").strip().lower() + + resolved_values: list[str] = [] + unresolved_flags: list[bool] = [] + for arg in args: + rendered, unresolved = _replace_tag_value_placeholders( + arg, + lookup, + preserve_unresolved=True, + ) + resolved_values.append(_strip_tag_value_function_arg(rendered)) + unresolved_flags.append(unresolved) + + if func in {"padding", "pad", "zfill"}: + if len(resolved_values) != 2 or any(unresolved_flags): + return None + width = _padding_width_from_spec(resolved_values[0]) + if width is None: + return None + return str(resolved_values[1]).zfill(width) + + if func == "default": + if len(resolved_values) != 2: + return None + primary = resolved_values[0] + fallback = resolved_values[1] + if not unresolved_flags[0] and str(primary).strip(): + return str(primary) + if unresolved_flags[1]: + return None + return str(fallback) + + if func == "replace": + if len(resolved_values) != 3 or any(unresolved_flags): + return None + return str(resolved_values[0]).replace( + str(resolved_values[1]), + str(resolved_values[2]), + ) + + if func in {"increment", "inc", "add"}: + if len(resolved_values) not in {1, 2}: + return None + if unresolved_flags[0]: + return None + base_value = _coerce_tag_value_integer(resolved_values[0]) + if base_value is None: + return None + step_value = 1 + if len(resolved_values) == 2: + if unresolved_flags[1]: + return None + parsed_step = _coerce_tag_value_integer(resolved_values[1]) + if parsed_step is None: + return None + step_value = parsed_step + return str(base_value + step_value) + + return None + + +def _render_tag_value_function_templates( + value: Any, + *, + lookup: Dict[str, List[str]], +) -> tuple[str, bool]: + text = str(value or "") + unresolved = False + + def _replace(match: re.Match[str]) -> str: + nonlocal unresolved + func_name = match.group(1) or "" + func_args = _split_tag_value_function_args(match.group(2) or "") + rendered = _apply_tag_value_function( + func_name, + func_args, + lookup=lookup, + ) + if rendered is None: + unresolved = True + return match.group(0) + return rendered + + previous = None + rendered = text + while previous != rendered and _TAG_VALUE_FUNCTION_RE.search(rendered): + previous = rendered + rendered = _TAG_VALUE_FUNCTION_RE.sub(_replace, rendered) + if unresolved: + break + return rendered, unresolved + + +def render_tag_value_templates( + tags: Sequence[Any], + *, + existing_tags: Optional[Iterable[Any]] = None, + result: Any = None, +) -> tuple[list[str], list[str]]: + """Resolve ``#(namespace)`` placeholders and ```` functions. + + Returns ``(resolved_tags, unresolved_templates)``. Tags whose placeholders + cannot be fully resolved are omitted from ``resolved_tags`` and returned in + ``unresolved_templates`` so callers can warn or summarize skipped items. + + Currently supported transforms: + - ```` or ```` for zero-padding + - ```` to fall back when a placeholder is missing + - ```` for simple substring replacement + - ```` for integer arithmetic + """ + + entries: list[dict[str, Any]] = [] + lookup = build_tag_value_lookup(existing_tags, result=result) + + for raw_tag in tags or []: + text = str(raw_tag or "").strip() + if not text: + continue + has_template = bool( + _TAG_VALUE_TEMPLATE_RE.search(text) + or _TAG_VALUE_FUNCTION_RE.search(text) + ) + entry = { + "raw": text, + "resolved": None, + "has_template": has_template, + } + if not has_template: + entry["resolved"] = text + _add_tag_values_to_lookup(lookup, text) + entries.append(entry) + + progress = True + while progress: + progress = False + for entry in entries: + if entry["resolved"] is not None or not entry["has_template"]: + continue + + rendered, unresolved = _replace_tag_value_placeholders( + entry["raw"], + lookup, + preserve_unresolved=bool(_TAG_VALUE_FUNCTION_RE.search(str(entry["raw"]))), + ) + + rendered, function_unresolved = _render_tag_value_function_templates( + rendered, + lookup=lookup, + ) + if function_unresolved: + continue + + if unresolved and _TAG_VALUE_TEMPLATE_RE.search(rendered): + continue + + rendered = rendered.strip() + if not rendered: + entry["resolved"] = "" + progress = True + continue + + entry["resolved"] = rendered + _add_tag_values_to_lookup(lookup, rendered) + progress = True + + resolved_tags = merge_sequences( + [entry["resolved"] for entry in entries if isinstance(entry.get("resolved"), str) and entry.get("resolved")], + case_sensitive=True, + ) + unresolved_templates = [ + str(entry["raw"]) + for entry in entries + if entry["has_template"] and not entry.get("resolved") + ] + return resolved_tags, unresolved_templates + + def fmt_bytes(n: Optional[int]) -> str: """Format bytes as human-readable with 1 decimal place (MB/GB). diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index c0619be..67205ee 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -337,6 +337,13 @@ class Add_File(Cmdlet): except Exception as exc: debug(f"[add-file] Directory scan failed: {exc}") + if result is None and not path_arg and not explicit_path_list_results and not dir_scan_results: + try: + if ctx.get_stage_context() is not None: + return 0 + except Exception: + pass + # Determine if -store targets a registered backend (vs a filesystem export path). is_storage_backend_location = False if location: @@ -354,6 +361,19 @@ class Add_File(Cmdlet): ) return 1 + plugin_storage_backend = None + if plugin_name: + plugin_storage_backend = Add_File._resolve_plugin_storage_backend( + plugin_name, + plugin_instance, + config, + store_instance=storage_registry, + ) + + effective_storage_backend_name = plugin_storage_backend or ( + str(location) if location and is_storage_backend_location else None + ) + # Decide which items to process. # - If directory scan was performed, use those results # - If user provided -path (and it was not reinterpreted as destination), treat this invocation as single-item. @@ -371,13 +391,6 @@ class Add_File(Cmdlet): else: items_to_process = [result] - if result is None and not path_arg and not explicit_path_list_results and not dir_scan_results: - try: - if ctx.get_stage_context() is not None: - return 0 - except Exception: - pass - total_items = len(items_to_process) if isinstance(items_to_process, list) else 0 processed_items = 0 try: @@ -549,15 +562,16 @@ class Add_File(Cmdlet): live_progress = None want_final_search_file = ( - bool(is_last_stage) and bool(is_storage_backend_location) - and bool(location) and bool(live_progress) + bool(is_last_stage) + and bool(effective_storage_backend_name) + and bool(live_progress) ) auto_search_file_after_add = False # When ingesting multiple items into a backend store, defer URL association and # apply it once at the end (bulk) to avoid per-item URL API calls. defer_url_association = ( - bool(is_storage_backend_location) and bool(location) + bool(effective_storage_backend_name) and len(items_to_process) > 1 ) @@ -642,7 +656,7 @@ class Add_File(Cmdlet): # When using -path (filesystem export), allow all file types. # When using -store (backend), restrict to SUPPORTED_MEDIA_EXTENSIONS. - allow_all_files = not (location and is_storage_backend_location) + allow_all_files = not bool(effective_storage_backend_name) if not self._validate_source(media_path, allow_all_extensions=allow_all_files): failures += 1 continue @@ -653,14 +667,33 @@ class Add_File(Cmdlet): progress.step("ingesting file") if plugin_name: - code = self._handle_plugin_upload( - media_path, - plugin_name, - plugin_instance, - pipe_obj, - config, - delete_after_item - ) + if effective_storage_backend_name: + code = self._handle_storage_backend( + item, + media_path, + effective_storage_backend_name, + pipe_obj, + config, + delete_after_item, + collect_payloads=collected_payloads, + collect_relationship_pairs=pending_relationship_pairs, + defer_url_association=defer_url_association, + pending_url_associations=pending_url_associations, + defer_tag_association=defer_url_association, + pending_tag_associations=pending_tag_associations, + suppress_last_stage_overlay=want_final_search_file, + auto_search_file=auto_search_file_after_add, + store_instance=storage_registry, + ) + else: + code = self._handle_plugin_upload( + media_path, + plugin_name, + plugin_instance, + pipe_obj, + config, + delete_after_item + ) if code == 0: successes += 1 else: @@ -1431,6 +1464,65 @@ class Add_File(Cmdlet): normalized = normalized.split(".", 1)[0] return normalized + @staticmethod + def _resolve_plugin_storage_backend( + plugin_name: Optional[Any], + instance_name: Optional[Any], + config: Dict[str, Any], + *, + store_instance: Optional[Any] = None, + ) -> Optional[str]: + plugin_key = Add_File._normalize_provider_key(plugin_name) + if not plugin_key: + return None + + from ProviderCore.registry import get_plugin_with_capability + + file_provider = get_plugin_with_capability(plugin_key, "upload", config) + if file_provider is None: + return None + + resolver = getattr(file_provider, "resolve_backend", None) + if not callable(resolver): + return None + + explicit_instance = str(instance_name or "").strip() or None + try: + storage = store_instance if store_instance is not None else Store(config) + except Exception: + storage = None + + try: + resolved_name, backend = resolver( + explicit_instance, + storage=storage, + require_explicit=bool(explicit_instance), + ) + except TypeError: + try: + resolved_name, backend = resolver(explicit_instance) + except Exception: + return None + except Exception: + return None + + if backend is None: + return None + + resolved_text = str(resolved_name or explicit_instance or "").strip() + if not resolved_text: + return None + + checker = getattr(file_provider, "is_backend", None) + if callable(checker): + try: + if not checker(backend, resolved_text): + return None + except Exception: + return None + + return resolved_text + @staticmethod def _maybe_download_plugin_result( result: Any, diff --git a/cmdlet/add_tag.py b/cmdlet/add_tag.py index e90a1e3..eb2ba64 100644 --- a/cmdlet/add_tag.py +++ b/cmdlet/add_tag.py @@ -22,6 +22,8 @@ SharedArgs = sh.SharedArgs normalize_hash = sh.normalize_hash parse_tag_arguments = sh.parse_tag_arguments expand_tag_groups = sh.expand_tag_groups +merge_sequences = sh.merge_sequences +render_tag_value_templates = sh.render_tag_value_templates parse_cmdlet_args = sh.parse_cmdlet_args collapse_namespace_tag = sh.collapse_namespace_tag should_show_help = sh.should_show_help @@ -524,6 +526,11 @@ class Add_Tag(Cmdlet): "- The source namespace must already exist in the file being tagged.", "- Target namespaces that already have a value are skipped (not overwritten).", "- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.", + "- Use #(namespace) inside a tag value to insert existing values, e.g. add-tag \"title:#(track) - #(series)\".", + "- Use angle-bracket transforms for advanced formatting, e.g. add-tag \"code:e\".", + "- Current documented transforms include padding, default, replace, and increment.", + "- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.", + "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.", ], exec=self.run, ) @@ -655,6 +662,7 @@ class Add_Tag(Cmdlet): # tag ARE provided - apply them to each store-backed result total_added = 0 total_modified = 0 + unresolved_template_count = 0 store_registry = Store(config, suppress_debug=True) @@ -791,6 +799,13 @@ class Add_Tag(Cmdlet): if new_tag.lower() not in existing_lower: item_tag_to_add.append(new_tag) + item_tag_to_add, unresolved_templates = render_tag_value_templates( + item_tag_to_add, + existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True), + result=res, + ) + unresolved_template_count += len(unresolved_templates) + item_tag_to_add = collapse_namespace_tag( item_tag_to_add, "title", @@ -962,6 +977,13 @@ class Add_Tag(Cmdlet): if new_tag.lower() not in existing_lower: item_tag_to_add.append(new_tag) + item_tag_to_add, unresolved_templates = render_tag_value_templates( + item_tag_to_add, + existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True), + result=res, + ) + unresolved_template_count += len(unresolved_templates) + item_tag_to_add = collapse_namespace_tag( item_tag_to_add, "title", @@ -1109,6 +1131,12 @@ class Add_Tag(Cmdlet): file=sys.stderr, ) + if unresolved_template_count > 0: + log( + f"[add_tag] skipped {unresolved_template_count} tag template(s) with unresolved #(namespace) placeholders", + file=sys.stderr, + ) + return 0 diff --git a/cmdlet/delete_tag.py b/cmdlet/delete_tag.py index b3d4171..67e11bf 100644 --- a/cmdlet/delete_tag.py +++ b/cmdlet/delete_tag.py @@ -11,6 +11,9 @@ CmdletArg = sh.CmdletArg SharedArgs = sh.SharedArgs normalize_hash = sh.normalize_hash parse_tag_arguments = sh.parse_tag_arguments +render_tag_value_templates = sh.render_tag_value_templates +merge_sequences = sh.merge_sequences +extract_tag_from_result = sh.extract_tag_from_result should_show_help = sh.should_show_help get_field = sh.get_field from SYS.logger import debug, log @@ -133,6 +136,11 @@ CMDLET = Cmdlet( detail=[ "- Requires a Hydrus file (hash present) or explicit -query override.", "- Multiple tags can be comma-separated or space-separated.", + "- Use #(namespace) inside a tag value to remove a derived tag, e.g. delete-tag \"title:#(track) - #(series)\".", + "- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e\".", + "- Current documented transforms include padding, default, replace, and increment.", + "- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.", + "- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.", ], ) @@ -225,7 +233,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: store_name = override_store or get_field(result, "store") path = get_field(result, "path") or get_field(result, "target") tags = [str(t) for t in grouped_tags if t] - return 0 if _process_deletion(tags, file_hash, path, store_name, config) else 1 + return 0 if _process_deletion(tags, file_hash, path, store_name, config, result=result) else 1 if not tags_arg and not has_piped_tag and not has_piped_tag_list: log("Requires at least one tag argument") @@ -316,7 +324,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: item_hash, item_path, item_store, - config): + config, + result=item): success_count += 1 if success_count > 0: @@ -331,6 +340,7 @@ def _process_deletion( store_name: str | None, config: Dict[str, Any], + result: Any = None, ) -> bool: """Helper to execute the deletion logic for a single target.""" @@ -367,12 +377,33 @@ def _process_deletion( except Exception: return [] + existing_tag_list = merge_sequences( + extract_tag_from_result(result), + _fetch_existing_tags(), + case_sensitive=True, + ) + + resolved_tags, unresolved_templates = render_tag_value_templates( + tags, + existing_tags=existing_tag_list, + result=result, + ) + if unresolved_templates: + log( + f"[delete_tag] skipped {len(unresolved_templates)} tag template(s) with unresolved #(namespace) placeholders", + file=sys.stderr, + ) + + tags = list(resolved_tags) + if not tags: + return False + # Safety: only block if this deletion would remove the final title tag title_tags = [ t for t in tags if isinstance(t, str) and t.lower().startswith("title:") ] if title_tags: - existing_tags = _fetch_existing_tags() + existing_tags = existing_tag_list current_titles = [ t for t in existing_tags if isinstance(t, str) and t.lower().startswith("title:") diff --git a/cmdlet/search_file.py b/cmdlet/search_file.py index 8671237..8b0a87f 100644 --- a/cmdlet/search_file.py +++ b/cmdlet/search_file.py @@ -1231,7 +1231,7 @@ class search_file(Cmdlet): log(f"No web results found for query: {search_query}", file=sys.stderr) if refresh_mode: try: - ctx.set_last_result_table_preserve_history(table, []) + ctx.set_last_result_table_overlay(table, []) except Exception: pass try: @@ -2089,7 +2089,7 @@ class search_file(Cmdlet): pass if refresh_mode: - ctx.set_last_result_table_preserve_history( + ctx.set_last_result_table_overlay( table, results_list ) @@ -2106,7 +2106,7 @@ class search_file(Cmdlet): if refresh_mode: try: table.title = command_title - ctx.set_last_result_table_preserve_history(table, []) + ctx.set_last_result_table_overlay(table, []) except Exception: pass db.append_worker_stdout(worker_id, _summarize_worker_results([])) @@ -2279,7 +2279,7 @@ class search_file(Cmdlet): if refresh_mode: try: table.title = command_title - ctx.set_last_result_table_preserve_history(table, []) + ctx.set_last_result_table_overlay(table, []) except Exception: pass db.append_worker_stdout(worker_id, _summarize_worker_results([])) diff --git a/cmdnat/__init__.py b/cmdnat/__init__.py index 63a8dab..6844f10 100644 --- a/cmdnat/__init__.py +++ b/cmdnat/__init__.py @@ -28,15 +28,20 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None: registry[alias.replace("_", "-").lower()] = run_fn -def register_native_commands(registry: Dict[str, CmdletFn]) -> None: - """Import native command modules and register their CMDLET exec functions.""" +def _iter_legacy_native_module_names() -> list[str]: base_dir = os.path.dirname(__file__) + module_names: list[str] = [] for filename in os.listdir(base_dir): if not (filename.endswith(".py") and not filename.startswith("_") and filename != "__init__.py"): continue + module_names.append(filename[:-3]) + return module_names - mod_name = filename[:-3] + +def register_native_commands(registry: Dict[str, CmdletFn]) -> None: + """Import legacy local command modules from cmdnat/ and register them.""" + for mod_name in _iter_legacy_native_module_names(): try: module = import_module(f".{mod_name}", __name__) cmdlet_obj = getattr(module, "CMDLET", None) diff --git a/cmdnat/out_table.py b/cmdnat/out_table.py deleted file mode 100644 index 7e8bf44..0000000 --- a/cmdnat/out_table.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import os -import re -import sys -from pathlib import Path -from typing import Any, Dict, Sequence, Optional - -from SYS.cmdlet_spec import Cmdlet, CmdletArg -from SYS.logger import log -from SYS import pipeline as ctx - -CMDLET = Cmdlet( - name=".out-table", - summary="Save the current result table to an SVG file.", - usage='.out-table -path "C:\\Path\\To\\Dir"', - arg=[ - CmdletArg( - "path", - type="string", - description="Directory (or file path) to write the SVG to", - required=True, - ), - ], - detail=[ - "Exports the most recent table (overlay/stage/last) as an SVG using Rich.", - "Default filename is derived from the table title (sanitized).", - "Examples:", - 'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop"', - 'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop\\my-table.svg"', - ], -) - -_WINDOWS_RESERVED_NAMES = { - "con", - "prn", - "aux", - "nul", - *(f"com{i}" for i in range(1, 10)), - *(f"lpt{i}" for i in range(1, 10)), -} -_ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]') - - -def _sanitize_filename_base(text: str) -> str: - """Sanitize a string for use as a Windows-friendly filename (no extension).""" - s = str(text or "").strip() - if not s: - return "table" - - # Replace characters illegal on Windows (and generally unsafe cross-platform). - s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s) - - # Drop control characters. - s = "".join(ch for ch in s if ch.isprintable()) - - # Collapse whitespace. - s = " ".join(s.split()).strip() - - # Windows disallows trailing space/dot. - s = s.rstrip(" .") - - if not s: - s = "table" - - # Avoid reserved device names. - if s.lower() in _WINDOWS_RESERVED_NAMES: - s = f"_{s}" - - # Keep it reasonably short. - if len(s) > 200: - s = s[:200].rstrip(" .") - - return s or "table" - - -def _resolve_output_path(path_arg: str, *, table_title: str) -> Path: - raw = str(path_arg or "").strip() - if not raw: - raise ValueError("-path is required") - - # Treat trailing slash as directory intent even if it doesn't exist yet. - ends_with_sep = raw.endswith((os.sep, os.altsep or "")) - - target = Path(raw) - - if target.exists() and target.is_dir(): - base = _sanitize_filename_base(table_title) - return target / f"{base}.svg" - - if ends_with_sep and not target.suffix: - target.mkdir(parents=True, exist_ok=True) - base = _sanitize_filename_base(table_title) - return target / f"{base}.svg" - - # File path intent. - if not target.suffix: - return target.with_suffix(".svg") - - if target.suffix.lower() != ".svg": - return target.with_suffix(".svg") - - return target - - -def _get_active_table(piped_result: Any) -> Optional[Any]: - # Prefer an explicit ResultTable passed through the pipe, but normally `.out-table` - # is used after `@` which pipes item selections (not the table itself). - if piped_result is not None and hasattr(piped_result, "__rich__"): - # Avoid mistakenly treating a dict/list as a renderable. - if piped_result.__class__.__name__ == "ResultTable": - return piped_result - - return ctx.get_display_table() or ctx.get_current_stage_table( - ) or ctx.get_last_result_table() - - -def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - args_list = [str(a) for a in (args or [])] - - # Simple flag parsing: `.out-table -path ` - path_arg: Optional[str] = None - i = 0 - while i < len(args_list): - low = args_list[i].strip().lower() - if low in {"-path", - "--path"} and i + 1 < len(args_list): - path_arg = args_list[i + 1] - i += 2 - continue - if not args_list[i].startswith("-") and path_arg is None: - # Allow `.out-table ` as a convenience. - path_arg = args_list[i] - i += 1 - - if not path_arg: - log("Missing required -path", file=sys.stderr) - return 1 - - table = _get_active_table(piped_result) - if table is None: - log("No table available to export", file=sys.stderr) - return 1 - - title = getattr(table, "title", None) - title_text = str(title or "table") - - try: - out_path = _resolve_output_path(path_arg, table_title=title_text) - out_path.parent.mkdir(parents=True, exist_ok=True) - - from rich.console import Console - - console = Console(record=True) - console.print(table) - console.save_svg(str(out_path)) - - log(f"Saved table SVG: {out_path}") - return 0 - except Exception as exc: - log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr) - return 1 - - -CMDLET.exec = _run diff --git a/cmdnat/status.py b/cmdnat/status.py index 8d797eb..910b435 100644 --- a/cmdnat/status.py +++ b/cmdnat/status.py @@ -41,7 +41,7 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int: try: # MPV check try: - from MPV.mpv_ipc import MPV + from plugins.mpv.mpv_ipc import MPV MPV() mpv_path = shutil.which("mpv") _add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available") diff --git a/cmdnat/table.py b/cmdnat/table.py index f220ebb..0633b1d 100644 --- a/cmdnat/table.py +++ b/cmdnat/table.py @@ -1,17 +1,335 @@ -from typing import Any, Dict, Sequence +from __future__ import annotations -from SYS.cmdlet_spec import Cmdlet, CmdletArg +import re +import sys +from pathlib import Path +from typing import Any, Dict, List, Sequence, Tuple + +from SYS.cmdlet_spec import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args from SYS.logger import log +from SYS.result_table import Column, Table +from SYS.rich_display import stdout_console + + +_NUMERIC_NAMESPACE_HINTS = { + "track", + "disk", + "disc", + "episode", + "season", + "chapter", + "volume", + "part", +} +_WINDOWS_RESERVED_NAMES = { + "con", + "prn", + "aux", + "nul", + *(f"com{i}" for i in range(1, 10)), + *(f"lpt{i}" for i in range(1, 10)), +} +_ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]') + + +def _normalize_bool(value: Any) -> bool: + text = str(value or "").strip().lower() + return text in {"1", "true", "yes", "on", "y"} + + +def _parse_table_query(query: Any) -> Dict[str, str]: + fields: Dict[str, str] = {} + raw = str(query or "").strip() + if not raw: + return fields + + for chunk in re.split(r"[;,]+", raw): + part = str(chunk or "").strip() + if not part: + continue + sep_index = part.find(":") + if sep_index < 0: + sep_index = part.find("=") + if sep_index <= 0: + continue + key = part[:sep_index].strip().lower() + value = part[sep_index + 1 :].strip().strip('"').strip("'") + if key: + fields[key] = value + return fields + + +def _active_table_bundle(ctx: Any) -> Tuple[Any, str]: + display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None + if display_table is not None: + return display_table, "display" + + current_stage_table = ctx.get_current_stage_table() if hasattr(ctx, "get_current_stage_table") else None + if current_stage_table is not None: + return current_stage_table, "stage" + + last_result_table = ctx.get_last_result_table() if hasattr(ctx, "get_last_result_table") else None + if last_result_table is not None: + return last_result_table, "last" + + return None, "" + + +def _clone_table(source: Any) -> Any: + if source is None or not isinstance(source, Table): + return source + + cloned = source.copy_with_title(str(getattr(source, "title", "") or "")) + for source_row in getattr(source, "rows", []) or []: + row = cloned.add_row() + row.columns = [ + Column(col.name, col.value, getattr(col, "width", None)) + for col in getattr(source_row, "columns", []) or [] + ] + row.selection_args = list(getattr(source_row, "selection_args", []) or []) or None + row.selection_action = list(getattr(source_row, "selection_action", []) or []) or None + row.source_index = getattr(source_row, "source_index", None) + row.payload = getattr(source_row, "payload", None) + return cloned + + +def _column_sort_key(value: Any, *, numeric: bool = False) -> Tuple[int, Any, str]: + text = str(value or "").strip() + if not text: + return (1, float("inf") if numeric else "", "") + if numeric: + match = re.search(r"-?\d+(?:\.\d+)?", text) + if match: + try: + return (0, float(match.group(0)), text.casefold()) + except Exception: + pass + return (0, float("inf"), text.casefold()) + return (0, text.casefold(), text.casefold()) + + +def _sort_by_column(table: Any, column_name: str, *, numeric: bool = False, reverse: bool = False) -> None: + if table is None or not hasattr(table, "rows"): + return + + wanted = str(column_name or "").strip().lower() + if not wanted: + return + + if wanted in {"title", "name"} and hasattr(table, "sort_by_title"): + table.sort_by_title() + if reverse and hasattr(table, "rows"): + table.rows.reverse() + return + + if wanted == "tag" and hasattr(table, "sort_by_title"): + table.rows.sort( + key=lambda row: _column_sort_key(row.get_column("Tag"), numeric=numeric), + reverse=bool(reverse), + ) + return + + table.rows.sort( + key=lambda row: _column_sort_key(row.get_column(column_name), numeric=numeric), + reverse=bool(reverse), + ) + + +def _reorder_items_from_table(table: Any, items: List[Any]) -> List[Any]: + if not items or table is None or not hasattr(table, "rows"): + return list(items or []) + + payloads: List[Any] = [] + for row in getattr(table, "rows", []) or []: + payload = getattr(row, "payload", None) + if payload is None: + payloads = [] + break + payloads.append(payload) + if payloads and len(payloads) == len(getattr(table, "rows", []) or []): + return payloads + + reordered: List[Any] = [] + for row in getattr(table, "rows", []) or []: + source_index = getattr(row, "source_index", None) + if isinstance(source_index, int) and 0 <= source_index < len(items): + reordered.append(items[source_index]) + + if reordered and len(reordered) == len(getattr(table, "rows", []) or []): + return reordered + return list(items or []) + + +def _render_table(table: Any) -> int: + if table is None: + log("No active result table", file=sys.stderr) + return 1 + + try: + if hasattr(table, "to_rich"): + stdout_console().print(table.to_rich()) + return 0 + except Exception as exc: + log(f"Failed to render table: {exc}", file=sys.stderr) + return 1 + + try: + print(table) + return 0 + except Exception as exc: + log(f"Failed to print table: {exc}", file=sys.stderr) + return 1 + + +def _sanitize_filename_base(text: str) -> str: + s = str(text or "").strip() + if not s: + return "table" + + s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s) + s = "".join(ch for ch in s if ch.isprintable()) + s = " ".join(s.split()).strip() + s = s.rstrip(" .") + + if not s: + s = "table" + if s.lower() in _WINDOWS_RESERVED_NAMES: + s = f"_{s}" + if len(s) > 200: + s = s[:200].rstrip(" .") + return s or "table" + + +def _resolve_output_path(path_arg: str, *, table_title: str) -> Path: + raw = str(path_arg or "").strip() + if not raw: + raise ValueError("-path is required") + + ends_with_sep = raw.endswith(("/", "\\")) + target = Path(raw) + + if target.exists() and target.is_dir(): + return target / f"{_sanitize_filename_base(table_title)}.svg" + + if (ends_with_sep or not target.suffix) and not target.exists(): + target.mkdir(parents=True, exist_ok=True) + return target / f"{_sanitize_filename_base(table_title)}.svg" + + if not target.suffix: + target.parent.mkdir(parents=True, exist_ok=True) + return target.with_suffix(".svg") + if target.suffix.lower() != ".svg": + return target.with_suffix(".svg") + return target + + +def _export_table_svg(table: Any, path_arg: str) -> int: + if table is None: + log("No table available to export", file=sys.stderr) + return 1 + + title_text = str(getattr(table, "title", None) or "table") + + try: + out_path = _resolve_output_path(path_arg, table_title=title_text) + out_path.parent.mkdir(parents=True, exist_ok=True) + + from rich.console import Console + + console = Console(record=True) + renderable = table.to_rich() if hasattr(table, "to_rich") else table + console.print(renderable) + console.save_svg(str(out_path)) + log(f"Saved table SVG: {out_path}") + return 0 + except Exception as exc: + log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr) + return 1 + + +def _apply_table_sort(table: Any, *, sort_column: str, query_text: str) -> int: + query_fields = _parse_table_query(query_text) + wanted_column = str(sort_column or query_fields.get("sort") or "").strip() + namespace = str(query_fields.get("namespace") or "").strip().rstrip(":") + order = str(query_fields.get("format") or query_fields.get("order") or "asc").strip().lower() + reverse = order in {"desc", "descending", "reverse", "z-a"} + + numeric_field = query_fields.get("numeric") + if numeric_field is not None: + numeric = _normalize_bool(numeric_field) + else: + numeric = namespace.casefold() in _NUMERIC_NAMESPACE_HINTS + + if not wanted_column and namespace: + wanted_column = "tag" + if not wanted_column: + wanted_column = "title" + + try: + if str(wanted_column).strip().lower() == "tag" and namespace: + if not hasattr(table, "sort_by_tag_namespace"): + log("Current table does not support namespace sorting", file=sys.stderr) + return 1 + table.sort_by_tag_namespace(namespace, numeric=numeric, reverse=reverse) + else: + _sort_by_column(table, wanted_column, numeric=numeric, reverse=reverse) + except Exception as exc: + log(f"Failed to sort table: {exc}", file=sys.stderr) + return 1 + + if hasattr(table, "_perseverance"): + try: + table._perseverance(True) + except Exception: + pass + return 0 def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: - # Debug utility: dump current pipeline table state (display/current/last + buffers) + _ = piped_result, config + try: from SYS import pipeline as ctx except Exception as exc: log(f"Failed to import pipeline context: {exc}") return 1 + parsed = parse_cmdlet_args(args, CMDLET) + sort_column = str(parsed.get("sort") or "").strip() + query_text = str(parsed.get("query") or "").strip() + debug_mode = bool(parsed.get("debug", False)) + print_mode = bool(parsed.get("print", False)) + path_arg = str(parsed.get("path") or "").strip() + + active_table, _table_kind = _active_table_bundle(ctx) + + if print_mode or path_arg: + if not path_arg: + log("Missing required -path for table export", file=sys.stderr) + return 1 + return _export_table_svg(active_table, path_arg) + + if not debug_mode and not sort_column and not query_text: + return _render_table(active_table) + + if not debug_mode and (sort_column or query_text): + base_table = active_table + if base_table is None: + log("No active result table to sort", file=sys.stderr) + return 1 + + working_table = _clone_table(base_table) + rc = _apply_table_sort(working_table, sort_column=sort_column, query_text=query_text) + if rc != 0: + return rc + + items = list(ctx.get_last_result_items() or []) + reordered_items = _reorder_items_from_table(working_table, items) + subject = ctx.get_last_result_subject() if hasattr(ctx, "get_last_result_subject") else None + ctx.set_last_result_table_overlay(working_table, reordered_items, subject) + ctx.set_current_stage_table(working_table) + return _render_table(working_table) + state = None try: state = ctx.get_pipeline_state() if hasattr(ctx, "get_pipeline_state") else None @@ -108,13 +426,39 @@ def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: CMDLET = Cmdlet( name=".table", - summary="Dump pipeline table state for debugging", - usage=".table [label]", + alias=["table"], + summary="Render, inspect, or sort the active result table.", + usage='.table [-sort ] [-query "format:asc|desc,namespace:track"] [-print -path ] [-debug [label]]', arg=[ + CmdletArg( + name="sort", + type="string", + description="Sort by a visible column name (for namespace tag sorting, use -sort tag with -query namespace:).", + required=False, + ), + CmdletArg( + name="query", + type="string", + description="Table options like format:asc|desc, namespace:track, numeric:true.", + required=False, + ), + CmdletArg( + name="print", + type="flag", + description="Export the active table as an SVG using -path.", + required=False, + ), + SharedArgs.PATH, + CmdletArg( + name="debug", + type="flag", + description="Dump pipeline table state for debugging instead of rendering the table.", + required=False, + ), CmdletArg( name="label", type="string", - description="Optional label to include in the dump", + description="Optional label to include in the debug dump", required=False, ), ], diff --git a/docs/MENU_TROUBLESHOOTING.md b/docs/MENU_TROUBLESHOOTING.md index 907cea2..8fed2a1 100644 --- a/docs/MENU_TROUBLESHOOTING.md +++ b/docs/MENU_TROUBLESHOOTING.md @@ -18,8 +18,8 @@ UOSC defines its own right-click menu, and there's no straightforward way to ove 3. **Right-click** - Attempts to trigger via input.conf, but UOSC overrides (needs investigation) ### How It Works -- `MPV/portable_config/input.conf` routes keybindings to Lua handlers -- `MPV/LUA/main.lua`: +- `plugins/mpv/portable_config/input.conf` routes keybindings to Lua handlers +- `plugins/mpv/LUA/main.lua`: - Registers script message handler: `medios-show-menu` - Registers Lua keybindings for 'm' and 'z' keys - Both route to `M.show_menu()` which opens an UOSC menu with items @@ -35,25 +35,25 @@ The menu calls UOSC's `open-menu` handler with JSON containing: - Start Helper (if not running) ## Files Modified -- `MPV/LUA/main.lua`: +- `plugins/mpv/LUA/main.lua`: - Fixed Lua syntax error (extra `end)`) - Added comprehensive `[MENU]` and `[KEY]` logging - Added 'm' and 'z' keybindings - Added `medios-show-menu` script message handler - Enhanced `M.show_menu()` with dual methods to call UOSC -- `MPV/portable_config/input.conf`: +- `plugins/mpv/portable_config/input.conf`: - Routes `mbtn_right` to `script-message medios-show-menu` - Routes 'm' key to `script-message medios-show-menu` -- `MPV/portable_config/mpv.conf`: +- `plugins/mpv/portable_config/mpv.conf`: - Fixed `audio-display` setting (was invalid `yes`, now `no`) ## Testing Run: `python test_menu.py` Or manually: -1. Start MPV with: `mpv --script=MPV/LUA/main.lua --config-dir=MPV/portable_config --idle` +1. Start MPV with: `mpv --script=plugins/mpv/LUA/main.lua --config-dir=plugins/mpv/portable_config --idle` 2. Press 'm' or 'z' key 3. Check logs at `Log/medeia-mpv-lua.log` for `[MENU]` entries diff --git a/docs/tag_template_syntax.md b/docs/tag_template_syntax.md new file mode 100644 index 0000000..37f37eb --- /dev/null +++ b/docs/tag_template_syntax.md @@ -0,0 +1,371 @@ +# Tag Template Syntax + +This guide documents the reusable template syntax for tag mutation commands such as `add-tag` and `delete-tag`. + +The current goal is lowercase-first tagging. Examples in this document use lowercase tag names and lowercase text values, and no case-conversion transforms are part of the documented syntax. + +## Where It Works + +The shared template resolver currently applies to: + +- `add-tag` +- `delete-tag` + +Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title. + +## Core Placeholder Syntax + +Use `#(namespace)` to insert the value from an existing namespaced tag. + +Examples: + +```powershell +add-tag "title:#(track) - #(series)" +add-tag "album:#(series)" +delete-tag "title:#(track) - #(series)" +``` + +If an item has: + +```text +track:9 +series:ancient greek intensive course +``` + +then: + +```text +title:#(track) - #(series) +``` + +resolves to: + +```text +title:9 - ancient greek intensive course +``` + +## Namespace Matching + +- Namespace matching is case-insensitive. +- Repeated whitespace inside the placeholder is normalized. +- A trailing `#` is ignored for compatibility, so `#(track #)` resolves the same way as `#(track)`. + +Examples: + +```powershell +add-tag "title:#(track #) - #(series)" +add-tag "code:#(disc number)" +``` + +## Transform Syntax + +Use angle brackets for transforms: + +```text + +``` + +Transforms run after `#(namespace)` placeholders are expanded. + +### Padding + +Use `padding`, `pad`, or `zfill` to zero-pad a value. + +Examples: + +```powershell +add-tag "code:e" +add-tag "code:e" +add-tag "code:e" +``` + +If `episode:3` exists, each example resolves to: + +```text +code:e03 +``` + +Padding width can be written in either of these forms: + +- `00` meaning width 2 +- `000` meaning width 3 +- `2` meaning width 2 +- `3` meaning width 3 + +### Default + +Use `default(value,fallback)` when a namespace may be missing. + +Examples: + +```powershell +add-tag "season:" +add-tag "disc:" +``` + +If `season:` is missing, the first example resolves to: + +```text +season:0 +``` + +### Replace + +Use `replace(value,old,new)` for simple substring replacement. + +Examples: + +```powershell +add-tag "slug:" +add-tag "slug:" +``` + +If `title:ancient greek intensive course` exists, the first example resolves to: + +```text +slug:ancient_greek_intensive_course +``` + +Quote a space when you want to replace literal spaces. Bare spaces are trimmed by argument parsing, so `' '` is the reliable form. + +### Increment + +Use `increment(value,amount)` to do small integer adjustments. + +Examples: + +```powershell +add-tag "episode_next:" +add-tag "disc_next:" +``` + +If `episode:3` exists, the first example resolves to: + +```text +episode_next:4 +``` + +The second argument is optional; `` also adds `1`. + +## Commas Inside Transforms + +Tag arguments still support comma-separated tags, but commas inside transform calls are preserved. + +This means the following stays as two tags, not three fragments: + +```powershell +add-tag "code:e,title:#(series)" +``` + +## Combining With `-extract` + +Templates are especially useful after deriving tags from a title. + +Example: + +```powershell +add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" +``` + +For a title like: + +```text +ancient greek intensive course - part 9 +``` + +this can derive: + +```text +series:ancient greek intensive course +track:9 +title:9 - ancient greek intensive course +``` + +## Missing Values + +If a placeholder or transform cannot be resolved, the whole templated tag is skipped instead of being written literally. + +Examples of skipped cases: + +- `title:#(missing_namespace)` when no such tag exists +- `code:` when the padding width is invalid + +The command logs a warning summary for skipped unresolved templates. + +## Recommended Patterns + +Episode-style numbering: + +```powershell +add-tag "code:e" +``` + +Title synthesis from extracted tags: + +```powershell +add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" +``` + +Delete a derived title tag: + +```powershell +delete-tag "title:#(track) - #(series)" +``` + +Reuse an existing value under a new namespace: + +```powershell +add-tag "album:#(series)" +``` + +## Mass Tagging Recipes + +These are the patterns most likely to be useful when cleaning or normalizing large existing tag sets. + +### Build A Stable Episode Code + +If items already have `episode:` values and you want a compact sortable code: + +```powershell +add-tag "code:e" +``` + +Examples: + +```text +episode:3 -> code:e03 +episode:12 -> code:e12 +``` + +### Build Season-Episode Style Labels + +If you already carry both season and episode values: + +```powershell +add-tag "code:se" +``` + +Examples: + +```text +season:1 +episode:3 +``` + +becomes: + +```text +code:s01e03 +``` + +### Fill Missing Season Values Before Building A Code + +If some items have episodes but no season tag yet: + +```powershell +add-tag "season:" "code:se" +``` + +That lets a later code template stay predictable even when the source metadata is incomplete. + +### Rebuild A Title From Existing Tags + +If you have normalized tags but want a cleaner `title:`: + +```powershell +add-tag "title:#(series) - #(track)" +``` + +or for episode-style material: + +```powershell +add-tag "title:#(series) e" +``` + +### Extract Then Reformat In One Pass + +If the current title is messy but predictable: + +```powershell +add-tag -extract "(series) - part (track)" "title:#(track) - #(series)" +``` + +This is useful when you want to convert display-oriented titles into searchable structured tags and then immediately synthesize a cleaner title back from them. + +### Promote Existing Values Into New Namespaces + +If one namespace already has the correct normalized value and you want to reuse it elsewhere: + +```powershell +add-tag "album:#(series)" +add-tag "label:#(publisher)" +add-tag "subtitle:#(title)" +``` + +### Create URL-Safe Or Filename-Safe Slugs + +If you want a simple underscore slug from an existing title: + +```powershell +add-tag "slug:" +``` + +For more involved slug cleanup, chain multiple commands over time by writing intermediate normalized tags instead of expecting one giant expression. + +### Create "Next Episode" Or Offset Tags + +If you need a helper value for ordering or automation: + +```powershell +add-tag "episode_next:" +``` + +or: + +```powershell +add-tag "episode_prev:" +``` + +### Delete A Derived Tag Predictably + +Once a tag was created from a template, you can remove it with the same template: + +```powershell +delete-tag "title:#(track) - #(series)" +delete-tag "code:se" +``` + +This is safer than manually typing the fully expanded value when doing bulk cleanup. + +### Keep Inputs Lowercase Upstream + +Because the documented system is lowercase-first, the cleanest workflows normalize source tags before using them in templates. + +Recommended pattern: + +- keep namespace names lowercase +- keep values lowercase when you create/import them +- use templates to compose values, not to fix letter casing later + +Examples: + +```text +series:ancient greek intensive course +episode:3 +publisher:oxford +``` + +compose more predictably than mixed-case sources. + +## Current Supported Syntax Summary + +- `#(namespace)` inserts an existing tag value +- `#(track #)` compatibility aliasing works for namespaces that include a trailing `#` +- `` zero-pads values +- `` is an alias of `padding` +- `` is an alias of `padding` +- `` uses a fallback when the primary value is missing +- `` performs plain substring replacement +- `` adds an integer offset, defaulting to `1` + +If more transforms are added later, they should follow the same angle-bracket function style rather than introducing a second expression format. \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md index 209545d..0630654 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -57,4 +57,4 @@ Bundled walkthrough: - The walkthrough is in [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) and shows `search-file -plugin ftp -instance `, folder drill-in via `@N`, file download routing, `@N | add-file -store ...`, and `add-file -plugin ftp -instance ` uploads. - The repo also includes an SCP example plugin in [plugins/scp/__init__.py](plugins/scp/__init__.py). - The walkthrough is in [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) and shows `search-file -plugin scp -instance `, SSH-backed directory drill-in, file download routing, `@N | add-file -store ...`, and `add-file -plugin scp -instance ` uploads. -- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives alongside it in [plugins/hydrusnetwork/api.py](plugins/hydrusnetwork/api.py), while [API/HydrusNetwork.py](API/HydrusNetwork.py) remains a compatibility shim. The provider delegates to configured `store.hydrusnetwork.*` backends so Hydrus features can be reached through the normal plugin registry without cmdlets importing Hydrus modules directly. \ No newline at end of file +- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives in the plugin-owned package [plugins/hydrusnetwork/api/__init__.py](plugins/hydrusnetwork/api/__init__.py), its registry-facing store adapter lives in [plugins/hydrusnetwork/store_proxy.py](plugins/hydrusnetwork/store_proxy.py), and its heavy internal operations live in [plugins/hydrusnetwork/store_backend.py](plugins/hydrusnetwork/store_backend.py). This `plugins//api/` package shape is the intended pattern for plugin-owned API helpers going forward. The provider now resolves configured Hydrus instances directly from plugin config instead of routing back through `Store.registry`; the proxy exists only so generic store callers can still target configured Hydrus stores. [API/HydrusNetwork.py](API/HydrusNetwork.py) and [Store/HydrusNetwork.py](Store/HydrusNetwork.py) are legacy compatibility shims only, and store discovery prefers the plugin-owned Hydrus hook over those shims. \ No newline at end of file diff --git a/plugins/alldebrid/__init__.py b/plugins/alldebrid/__init__.py index 0e0e582..e7ffcd1 100644 --- a/plugins/alldebrid/__init__.py +++ b/plugins/alldebrid/__init__.py @@ -12,7 +12,7 @@ from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple from urllib.parse import urlparse from API.HTTP import HTTPClient, _download_direct_file -from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_torrent_file +from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file from ProviderCore.base import Provider, SearchResult from SYS.provider_helpers import TableProviderMixin from SYS.item_accessors import get_field as _extract_value @@ -859,7 +859,7 @@ class AllDebrid(TableProviderMixin, Provider): return None try: - from API.alldebrid import AllDebridClient + from plugins.alldebrid.api import AllDebridClient client = AllDebridClient(api_key) except Exception as exc: @@ -1400,7 +1400,7 @@ class AllDebrid(TableProviderMixin, Provider): view = view or "folders" try: - from API.alldebrid import AllDebridClient + from plugins.alldebrid.api import AllDebridClient client = AllDebridClient(api_key) except Exception as exc: diff --git a/API/alldebrid.py b/plugins/alldebrid/api/__init__.py similarity index 99% rename from API/alldebrid.py rename to plugins/alldebrid/api/__init__.py index 78be25f..19378e9 100644 --- a/API/alldebrid.py +++ b/plugins/alldebrid/api/__init__.py @@ -15,7 +15,7 @@ from typing import Any, Dict, Optional, Set, List, Sequence, Tuple from urllib.parse import urlparse from SYS.logger import log, debug -from .HTTP import HTTPClient +from API.HTTP import HTTPClient logger = logging.getLogger(__name__) diff --git a/plugins/hifi/__init__.py b/plugins/hifi/__init__.py index 631e4fb..1144d2e 100644 --- a/plugins/hifi/__init__.py +++ b/plugins/hifi/__init__.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse -from API.Tidal import ( +from plugins.tidal.api import ( Tidal as TidalApiClient, build_track_tags, coerce_duration_seconds, diff --git a/plugins/loc/__init__.py b/plugins/loc/__init__.py index 23ce68d..37d3c82 100644 --- a/plugins/loc/__init__.py +++ b/plugins/loc/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional -from API.loc import LOCClient +from plugins.loc.api import LOCClient from ProviderCore.base import Provider, SearchResult from SYS.cli_syntax import get_free_text, parse_query from SYS.logger import log diff --git a/API/loc.py b/plugins/loc/api/__init__.py similarity index 98% rename from API/loc.py rename to plugins/loc/api/__init__.py index 9a71d93..016b9fd 100644 --- a/API/loc.py +++ b/plugins/loc/api/__init__.py @@ -14,7 +14,7 @@ from __future__ import annotations from typing import Any, Dict, Optional -from .base import API, ApiError +from API.base import API, ApiError class LOCError(ApiError): diff --git a/cmdnat/matrix.py b/plugins/matrix/commands.py similarity index 99% rename from cmdnat/matrix.py rename to plugins/matrix/commands.py index e454e40..a56c3a1 100644 --- a/cmdnat/matrix.py +++ b/plugins/matrix/commands.py @@ -14,15 +14,15 @@ from SYS.logger import log, debug from SYS.result_table import Table from SYS.item_accessors import get_sha256_hex from SYS.utils import extract_hydrus_hash_from_url -from SYS import pipeline as ctx -from ProviderCore.registry import get_plugin, get_plugin_for_url -from cmdnat._parsing import ( +from SYS.command_parsing import ( extract_arg_value, extract_piped_value as _extract_piped_value, extract_value_arg as _extract_value_arg, has_flag as _has_flag, normalize_to_list as _normalize_to_list, ) +from SYS import pipeline as ctx +from ProviderCore.registry import get_plugin, get_plugin_for_url _MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items" _MATRIX_PENDING_TEXT_KEY = "matrix_pending_text" @@ -1297,3 +1297,5 @@ CMDLET = Cmdlet( ], exec=_run, ) + +COMMANDS = [CMDLET] diff --git a/plugins/metadata_provider.py b/plugins/metadata_provider.py index e2d6bc7..c9db1d2 100644 --- a/plugins/metadata_provider.py +++ b/plugins/metadata_provider.py @@ -15,7 +15,7 @@ try: from plugins.tidal import Tidal except ImportError: # pragma: no cover - optional Tidal = None -from API.Tidal import ( +from plugins.tidal.api import ( build_track_tags, extract_artists, stringify, diff --git a/MPV/LUA/main.lua b/plugins/mpv/LUA/main.lua similarity index 98% rename from MPV/LUA/main.lua rename to plugins/mpv/LUA/main.lua index ce0f777..37bc93b 100644 --- a/MPV/LUA/main.lua +++ b/plugins/mpv/LUA/main.lua @@ -396,6 +396,8 @@ function M._sync_uosc_cursor(reason) if ensure_uosc_loaded() then pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor') end + M._disable_input_section('input_uosc', why .. suffix) + M._disable_input_section('input_forced_uosc', why .. suffix) M._disable_input_section('input_console', why .. suffix) M._disable_input_section('input_forced_console', why .. suffix) M._disable_input_section('image', why .. suffix) @@ -410,6 +412,19 @@ function M._sync_uosc_cursor(reason) end) end +function M._schedule_uosc_cursor_resync(reason) + local why = tostring(reason or 'unknown') + local delays = { 0.05, 0.20, 0.60 } + for _, delay in ipairs(delays) do + mp.add_timeout(delay, function() + local video_info = mp.get_property_native('current-tracks/video') + if not (type(video_info) == 'table' and video_info.image == true) then + M._sync_uosc_cursor(why .. '@' .. tostring(delay)) + end + end) + end +end + function M._close_uosc_menu_and_sync(menu_type, reason) local why = tostring(reason or 'unknown') if ensure_uosc_loaded() then @@ -425,6 +440,8 @@ end M._reset_uosc_input_state = function(reason) local why = tostring(reason or 'unknown') + M._disable_input_section('input_uosc', why) + M._disable_input_section('input_forced_uosc', why) M._disable_input_section('input_console', why) M._disable_input_section('input_forced_console', why) M._disable_input_section('image', why) @@ -529,7 +546,7 @@ local function trim(s) end -- Lyrics overlay toggle --- The Python helper (python -m MPV.lyric) will read this property via IPC. +-- The Python helper (python -m plugins.mpv.lyric) will read this property via IPC. local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible" local function lyric_get_visible() @@ -679,7 +696,11 @@ end local function _detect_format_probe_script() local repo_root = _detect_repo_root() if repo_root ~= '' then - local direct = utils.join_path(repo_root, 'MPV/format_probe.py') + local direct = utils.join_path(repo_root, 'plugins/mpv/format_probe.py') + if _path_exists(direct) then + return direct + end + direct = utils.join_path(repo_root, 'MPV/format_probe.py') if _path_exists(direct) then return direct end @@ -690,6 +711,9 @@ local function _detect_format_probe_script() local source_dir = _get_lua_source_path():match('(.*)[/\\]') or '' local script_dir = mp.get_script_directory() or '' local cwd = utils.getcwd() or '' + _append_unique_path(candidates, seen, find_file_upwards(source_dir, 'plugins/mpv/format_probe.py', 8)) + _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/format_probe.py', 8)) + _append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/format_probe.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(source_dir, 'MPV/format_probe.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/format_probe.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/format_probe.py', 8)) @@ -771,10 +795,12 @@ local function _build_sibling_script_candidates(file_name) _append_unique_path(candidates, seen, script_dir .. '/' .. file_name) _append_unique_path(candidates, seen, script_dir .. '/LUA/' .. file_name) _append_unique_path(candidates, seen, script_dir .. '/../' .. file_name) + _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/LUA/' .. file_name, 8)) _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/LUA/' .. file_name, 8)) end if cwd ~= '' then + _append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/LUA/' .. file_name, 8)) _append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/LUA/' .. file_name, 8)) end @@ -1412,10 +1438,16 @@ local function attempt_start_pipeline_helper_async(callback) local helper_script = '' local repo_root = _detect_repo_root() if repo_root ~= '' then - local direct = utils.join_path(repo_root, 'MPV/pipeline_helper.py') + local direct = utils.join_path(repo_root, 'plugins/mpv/pipeline_helper.py') if _path_exists(direct) then helper_script = direct end + if helper_script == '' then + direct = utils.join_path(repo_root, 'MPV/pipeline_helper.py') + if _path_exists(direct) then + helper_script = direct + end + end end if helper_script == '' then local candidates = {} @@ -1423,6 +1455,9 @@ local function attempt_start_pipeline_helper_async(callback) local source_dir = _get_lua_source_path():match('(.*)[/\\]') or '' local script_dir = mp.get_script_directory() or '' local cwd = utils.getcwd() or '' + _append_unique_path(candidates, seen, find_file_upwards(source_dir, 'plugins/mpv/pipeline_helper.py', 8)) + _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/pipeline_helper.py', 8)) + _append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/pipeline_helper.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(source_dir, 'MPV/pipeline_helper.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/pipeline_helper.py', 8)) _append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/pipeline_helper.py', 8)) @@ -1441,7 +1476,7 @@ local function attempt_start_pipeline_helper_async(callback) local launch_root = repo_root if launch_root == '' then - launch_root = helper_script:match('(.*)[/\\]MPV[/\\]') or (helper_script:match('(.*)[/\\]') or '') + launch_root = helper_script:match('(.*)[/\\]plugins[/\\]mpv[/\\]') or helper_script:match('(.*)[/\\]MPV[/\\]') or (helper_script:match('(.*)[/\\]') or '') end local bootstrap = table.concat({ @@ -1528,7 +1563,7 @@ function M._resolve_repo_script(relative_path) for _, candidate in ipairs(candidates) do if _path_exists(candidate) then - local launch_root = candidate:match('(.*)[/\\]MPV[/\\]') or (candidate:match('(.*)[/\\]') or '') + local launch_root = candidate:match('(.*)[/\\]plugins[/\\]mpv[/\\]') or candidate:match('(.*)[/\\]MPV[/\\]') or (candidate:match('(.*)[/\\]') or '') return candidate, launch_root end end @@ -1561,9 +1596,12 @@ function M._attempt_start_lyric_helper_async(reason) return false end - local lyric_script, launch_root = M._resolve_repo_script('MPV/lyric.py') + local lyric_script, launch_root = M._resolve_repo_script('plugins/mpv/lyric.py') if lyric_script == '' then - _lua_log('lyric-helper: MPV/lyric.py not found reason=' .. tostring(reason)) + lyric_script, launch_root = M._resolve_repo_script('MPV/lyric.py') + end + if lyric_script == '' then + _lua_log('lyric-helper: lyric.py not found reason=' .. tostring(reason)) return false end @@ -2852,9 +2890,7 @@ local function _commit_pending_screenshot(tags) end local function _apply_screenshot_tag_query(query) - pcall(function() - mp.commandv('script-message-to', 'uosc', 'close-menu', SCREENSHOT_TAG_MENU_TYPE) - end) + M._close_uosc_menu_and_sync(SCREENSHOT_TAG_MENU_TYPE, 'screenshot-tags-submit') _commit_pending_screenshot(_normalize_tag_list(query)) end @@ -2888,7 +2924,9 @@ local function _open_screenshot_tag_prompt(store, out_path) }, } - mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) + if not M._open_uosc_menu(menu_data, 'screenshot-tag-prompt') then + _commit_pending_screenshot(nil) + end end local function _open_store_picker_for_pending_screenshot() @@ -3320,8 +3358,8 @@ local function _open_sleep_timer_prompt() items = items, } - if ensure_uosc_loaded() then - mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) + if M._open_uosc_menu(menu_data, 'sleep-timer-prompt') then + return else mp.osd_message('Sleep timer unavailable (uosc not loaded)', 2.0) end @@ -3336,9 +3374,7 @@ local function _apply_sleep_timer_query(query) if minutes <= 0 then _cancel_sleep_timer(true) - pcall(function() - mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE) - end) + M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-cancel') return end @@ -3354,9 +3390,7 @@ local function _apply_sleep_timer_query(query) mp.osd_message(string.format('Sleep timer set: %d min', math.floor(minutes + 0.5)), 1.5) _lua_log('sleep: timer set minutes=' .. tostring(minutes) .. ' seconds=' .. tostring(seconds)) - pcall(function() - mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE) - end) + M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-submit') end local function _handle_sleep_timer_event(json) @@ -3445,6 +3479,7 @@ end local function _deactivate_image_controls() if not ImageControl.enabled then _disable_image_section() + M._schedule_uosc_cursor_resync('image-controls-disabled-idle') return end ImageControl.enabled = false @@ -3459,6 +3494,7 @@ local function _deactivate_image_controls() mp.set_property_number('video-pan-y', 0) mp.set_property('video-align-x', '0') mp.set_property('video-align-y', '0') + M._schedule_uosc_cursor_resync('image-controls-disabled') end local function _update_image_mode() @@ -3472,6 +3508,9 @@ end mp.register_event('file-loaded', function() _update_image_mode() + if not _get_current_item_is_image() then + M._schedule_uosc_cursor_resync('file-loaded') + end end) mp.register_event('shutdown', function() @@ -5709,6 +5748,24 @@ mp.observe_property('track-list', 'native', function() M._ensure_current_subtitles_visible('observe-track-list') end) +mp.observe_property('window-minimized', 'bool', function(_name, value) + if value == true then + _lua_log('window: minimized -> reset uosc input state') + M._reset_uosc_input_state('window-minimized') + return + end + + _lua_log('window: restored -> reset uosc input state') + M._reset_uosc_input_state('window-restored') + M._schedule_uosc_cursor_resync('window-restored') + mp.add_timeout(0.10, function() + M._reset_uosc_input_state('window-restored@0.10') + end) + mp.add_timeout(0.35, function() + M._schedule_uosc_cursor_resync('window-restored@0.35') + end) +end) + mp.observe_property('ytdl-raw-info', 'native', function(_name, value) if type(value) ~= 'table' then return diff --git a/MPV/LUA/sleep_timer.lua b/plugins/mpv/LUA/sleep_timer.lua similarity index 100% rename from MPV/LUA/sleep_timer.lua rename to plugins/mpv/LUA/sleep_timer.lua diff --git a/MPV/LUA/trim.lua b/plugins/mpv/LUA/trim.lua similarity index 100% rename from MPV/LUA/trim.lua rename to plugins/mpv/LUA/trim.lua diff --git a/plugins/mpv/__init__.py b/plugins/mpv/__init__.py new file mode 100644 index 0000000..d3a8451 --- /dev/null +++ b/plugins/mpv/__init__.py @@ -0,0 +1,5 @@ +from plugins.mpv.mpv_ipc import MPV + +__all__ = [ + "MPV", +] diff --git a/cmdnat/pipe.py b/plugins/mpv/commands.py similarity index 94% rename from cmdnat/pipe.py rename to plugins/mpv/commands.py index a187c06..4987c54 100644 --- a/cmdnat/pipe.py +++ b/plugins/mpv/commands.py @@ -10,10 +10,10 @@ from datetime import datetime, timedelta from urllib.parse import urlparse, parse_qs from pathlib import Path from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args -from ProviderCore.registry import get_plugin, get_plugin_for_url +from ProviderCore.registry import get_plugin, get_plugin_for_url, list_plugin_names_with_capability from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream from SYS.result_table import Table -from MPV.mpv_ipc import MPV +from plugins.mpv.mpv_ipc import MPV from SYS import pipeline as ctx from SYS.models import PipeObject @@ -32,13 +32,6 @@ _IPV4_RE = re.compile(r"^\d+\.\d+\.\d+\.\d+$") _MPD_PATH_RE = re.compile(r"\.mpd($|\?)") -def _get_hydrus_provider(config: Optional[Dict[str, Any]] = None) -> Any: - try: - return get_plugin("hydrusnetwork", config or {}) - except Exception: - return None - - def _repo_root() -> Path: try: return Path(__file__).resolve().parent.parent @@ -564,6 +557,111 @@ def _resolve_plugin_playback_path(item: Any, config: Optional[Dict[str, Any]]) - return None +def _iter_provider_hook_candidates( + capability: str, + *, + config: Optional[Dict[str, Any]] = None, + targets: Optional[Sequence[str]] = None, +) -> List[Any]: + providers: List[Any] = [] + seen: set[str] = set() + + for target in targets or (): + try: + provider = get_plugin_for_url(str(target or ""), config or {}) + except Exception: + provider = None + if provider is None: + continue + name = str(getattr(provider, "name", "") or "").strip().lower() + if name and name not in seen: + seen.add(name) + providers.append(provider) + + try: + provider_names = list_plugin_names_with_capability(capability) + except Exception: + provider_names = [] + + for provider_name in provider_names: + try: + provider = get_plugin(provider_name, config or {}) + except Exception: + provider = None + if provider is None: + continue + name = str(getattr(provider, "name", provider_name) or provider_name).strip().lower() + if name and name not in seen: + seen.add(name) + providers.append(provider) + + return providers + + +def _resolve_provider_item_context( + item: Any, + *, + metadata: Optional[Dict[str, Any]], + store: Optional[str], + file_hash: Optional[str], + targets: Sequence[str], + config: Optional[Dict[str, Any]] = None, +) -> tuple[Optional[str], Optional[str]]: + resolved_store = store + resolved_hash = file_hash + + for provider in _iter_provider_hook_candidates( + "pipe-item-context", + config=config, + targets=targets, + ): + try: + result = provider.resolve_pipe_item_context( + item, + metadata=metadata, + store=resolved_store, + file_hash=resolved_hash, + targets=targets, + ) + except Exception: + continue + if not result or not isinstance(result, tuple) or len(result) != 2: + continue + next_store, next_hash = result + if next_store: + resolved_store = str(next_store).strip() + if next_hash: + resolved_hash = str(next_hash).strip().lower() + + return resolved_store, resolved_hash + + +def _infer_provider_playlist_store( + item: Any, + *, + target: str, + file_storage: Any = None, + config: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + for provider in _iter_provider_hook_candidates( + "playlist-store", + config=config, + targets=[target], + ): + try: + resolved = provider.infer_playlist_store( + item, + target=target, + file_storage=file_storage, + ) + except Exception: + continue + text = str(resolved or "").strip() + if text: + return text + return None + + def _ensure_lyric_overlay(mpv: MPV) -> None: try: mpv.ensure_lyric_loader_running() @@ -638,29 +736,17 @@ def _extract_store_and_hash( except Exception: file_hash = None - hydrus_provider = _get_hydrus_provider(config) - if hydrus_provider is not None: - normalized_store = None - try: - if store and hydrus_provider.is_store_name(store): - normalized_store = store - except Exception: - normalized_store = None + store, file_hash = _resolve_provider_item_context( + item, + metadata=metadata if isinstance(metadata, dict) else None, + store=store, + file_hash=file_hash, + targets=targets, + config=config, + ) - for target in targets: - try: - parsed_store, parsed_hash = hydrus_provider.parse_hydrus_url(target) - except Exception: - parsed_store, parsed_hash = None, "" - if parsed_hash and not file_hash: - file_hash = parsed_hash - if parsed_store: - normalized_store = parsed_store - - if normalized_store: - store = normalized_store - elif store and store.upper() in {"PATH", "LOCAL", "UNKNOWN"}: - store = None + if store and store.upper() in {"PATH", "LOCAL", "UNKNOWN"}: + store = None if not file_hash: try: @@ -725,7 +811,7 @@ def _prefetch_notes_async( def _worker() -> None: try: - from MPV.lyric import ( + from plugins.mpv.lyric import ( load_cached_notes, set_notes_prefetch_pending, store_cached_notes, @@ -754,7 +840,7 @@ def _prefetch_notes_async( debug(f"MPV note prefetch failed for {key}: {exc}", file=sys.stderr) finally: try: - from MPV.lyric import set_notes_prefetch_pending + from plugins.mpv.lyric import set_notes_prefetch_pending set_notes_prefetch_pending(store, file_hash, False) except Exception: @@ -849,62 +935,6 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]: return None -def _find_hydrus_instance_for_hash( - hash_str: str, - file_storage: Any, - *, - config: Optional[Dict[str, Any]] = None, -) -> Optional[str]: - """Find which Hydrus instance serves a specific file hash. - - Args: - hash_str: SHA256 hash (64 hex chars) - file_storage: FileStorage instance with Hydrus backends - - Returns: - Instance name (e.g., 'home') or None if not found - """ - hydrus_provider = _get_hydrus_provider(config) - if hydrus_provider is None: - return None - - try: - for backend_name, _backend in hydrus_provider.iter_backends(): - try: - if hydrus_provider.hash_exists(hash_str, store_name=str(backend_name)): - return str(backend_name) - except Exception: - continue - except Exception: - return None - - return None - - -def _find_hydrus_instance_by_url( - url: str, - file_storage: Any, - *, - config: Optional[Dict[str, Any]] = None, -) -> Optional[str]: - """Find which Hydrus instance matches a given URL. - - Args: - url: Full URL (e.g., http://localhost:45869/get_files/file?hash=...) - file_storage: FileStorage instance with Hydrus backends - - Returns: - Instance name (e.g., 'home') or None if not found - """ - hydrus_provider = _get_hydrus_provider(config) - if hydrus_provider is None: - return None - try: - return hydrus_provider.match_store_name_for_url(url) - except Exception: - return None - - def _normalize_playlist_path(text: Optional[str]) -> Optional[str]: """Normalize playlist entry paths for dedupe comparisons.""" if not text: @@ -960,29 +990,18 @@ def _infer_store_from_playlist_item( if memory_target: target = memory_target - # Hydrus hashes: bare 64-hex entries - if _SHA256_FULL_RE.fullmatch(target.lower()): - # If we have file_storage, query each Hydrus instance to find which one has this hash - if file_storage: - hash_str = target.lower() - hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - return "hydrus" + provider_store = _infer_provider_playlist_store( + item, + target=target, + file_storage=file_storage, + config=config, + ) + if provider_store: + return provider_store lower = target.lower() if lower.startswith("magnet:"): return "magnet" - if lower.startswith("hydrus://"): - # Extract hash from hydrus:// URL if possible - if file_storage: - hash_match = _SHA256_RE.search(target.lower()) - if hash_match: - hash_str = hash_match.group(0) - hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - return "hydrus" # Windows / UNC paths if _WINDOWS_PATH_RE.match(target) or target.startswith("\\\\"): @@ -1010,35 +1029,6 @@ def _infer_store_from_playlist_item( return "soundcloud" if "bandcamp" in host_stripped: return "bandcamp" - if "get_files" in path or "file?hash=" in path or host_stripped in {"127.0.0.1", - "localhost"}: - # Hydrus API URL - try to extract hash and find instance - if file_storage: - # Try to extract hash from URL parameters - hash_match = _HASH_QUERY_RE.search(target.lower()) - if hash_match: - hash_str = hash_match.group(1) - hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - # If no hash in URL, try matching the base URL to configured instances - hydrus_instance = _find_hydrus_instance_by_url(target, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - return "hydrus" - if _IPV4_RE.match(host_stripped) and "get_files" in path: - # IP-based Hydrus URL - if file_storage: - hash_match = _HASH_QUERY_RE.search(target.lower()) - if hash_match: - hash_str = hash_match.group(1) - hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - hydrus_instance = _find_hydrus_instance_by_url(target, file_storage, config=config) - if hydrus_instance: - return hydrus_instance - return "hydrus" parts = host_stripped.split(".") if len(parts) >= 2: @@ -1444,29 +1434,27 @@ def _get_playable_path( # - MPV IPC pipe (transport) # - PipeObject (pipeline data) backend_target_resolved = False - hydrus_provider = _get_hydrus_provider(config) - if store and file_hash and file_hash != "unknown" and file_storage: + if store and file_hash and file_hash != "unknown": try: - backend = file_storage[store] - except Exception: - backend = None + resolved_path = _resolve_plugin_playback_path( + { + "store": str(store), + "hash": str(file_hash), + "path": path, + "url": path, + }, + config, + ) + except Exception as e: + debug( + f"Error resolving playback path from store '{store}': {e}", + file=sys.stderr, + ) + resolved_path = None - if backend is not None: + if resolved_path: backend_target_resolved = True - - # Hydrus playback should resolve via the provider so store aliases and URL building stay centralized. - if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(store)): - try: - resolved_path = hydrus_provider.build_file_url(file_hash, store_name=str(store)) - if resolved_path: - path = resolved_path - except Exception as e: - debug( - f"Error building Hydrus URL from store '{store}': {e}", - file=sys.stderr - ) - else: - backend_target_resolved = False + path = resolved_path if isinstance(path, str) and path.startswith(("http://", "https://")) and not backend_target_resolved: return (path, title) @@ -1735,7 +1723,7 @@ def _queue_items( "request_id": 199, } - _send_ipc_command(header_cmd, silent=True, wait=False) + _send_ipc_command(header_cmd, silent=True, wait=True) if effective_ytdl_opts: ytdl_cmd = { "command": @@ -1744,7 +1732,7 @@ def _queue_items( effective_ytdl_opts], "request_id": 197, } - _send_ipc_command(ytdl_cmd, silent=True, wait=False) + _send_ipc_command(ytdl_cmd, silent=True, wait=True) # For memory:// M3U payloads (used to carry titles), use loadlist so mpv parses # the content as a playlist and does not expose #EXTINF lines as entries. @@ -2227,16 +2215,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: ) if isinstance(playlist_after, list) else 0 - should_autoplay = False - if idle_before is True: - should_autoplay = True - elif isinstance(playlist_before, - list) and len(playlist_before) == 0: - should_autoplay = True - - if should_autoplay and after_len > 0: + idx_to_play: Optional[int] = None + if after_len > before_len: idx_to_play = min(max(0, before_len), after_len - 1) + elif idle_before is True and after_len > 0: + idx_to_play = after_len - 1 + elif isinstance(playlist_before, + list) and len(playlist_before) == 0 and after_len > 0: + idx_to_play = after_len - 1 + if idx_to_play is not None: # Prefer the store/hash from the piped item when auto-playing. try: s, h = _extract_store_and_hash(items_to_add[0], config=config) @@ -2276,6 +2264,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: items = _get_playlist(silent=True) if items is None: + mpv_process_alive = False + try: + mpv_process_alive = MPV().has_process_owner() + except Exception: + mpv_process_alive = False + if mpv_started: # MPV was just started, retry getting playlist after a brief delay import time @@ -2288,6 +2282,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: debug("MPV is starting up...") return 0 else: + if mpv_process_alive: + debug("MPV is already running, but the Windows IPC pipe is busy. Not starting a second instance.") + return 0 + # Do not auto-launch MPV when no action/inputs were provided; avoid surprise startups no_inputs = not any( [ @@ -2548,6 +2546,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: helper_status = "not running" if helper_heartbeat not in (None, "", "0", False): helper_status = f"running ({helper_heartbeat})" + else: + try: + mpv_live = MPV() + if mpv_live.has_process_owner(): + helper_status = "ipc busy or helper-owned connection" + except Exception: + pass print(f"Pipeline helper: {helper_status}") @@ -2823,3 +2828,5 @@ CMDLET = Cmdlet( ], exec=_run, ) + +COMMANDS = [CMDLET] diff --git a/plugins/mpv/format_probe.py b/plugins/mpv/format_probe.py new file mode 100644 index 0000000..ec7b680 --- /dev/null +++ b/plugins/mpv/format_probe.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +from pathlib import Path +from typing import Any, Dict + +def _repo_root() -> Path: + package_dir = Path(__file__).resolve().parent + if package_dir.name.lower() == "mpv" and package_dir.parent.name.lower() == "plugins": + return package_dir.parent.parent + return package_dir.parent + + +REPO_ROOT = _repo_root() +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if not args: + payload: Dict[str, Any] = { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing url", + "table": None, + } + print(json.dumps(payload, ensure_ascii=False)) + return 2 + + url = str(args[0] or "").strip() + captured_stdout = io.StringIO() + captured_stderr = io.StringIO() + with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr): + from plugins.mpv.pipeline_helper import _run_op + + payload = _run_op("ytdlp-formats", {"url": url}) + + noisy_stdout = captured_stdout.getvalue().strip() + noisy_stderr = captured_stderr.getvalue().strip() + if noisy_stdout: + payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout])) + if noisy_stderr: + payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr])) + + print(json.dumps(payload, ensure_ascii=False)) + return 0 if payload.get("success") else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/plugins/mpv/lyric.py b/plugins/mpv/lyric.py new file mode 100644 index 0000000..d9d3a67 --- /dev/null +++ b/plugins/mpv/lyric.py @@ -0,0 +1,2036 @@ +r"""Timed lyric overlay for mpv via JSON IPC. + +This is intentionally implemented from scratch (no vendored/copied code) while +providing the same *kind* of functionality as popular mpv lyric scripts: +- Parse LRC (timestamped lyrics) +- Track mpv playback time via IPC +- Show the current line on mpv's OSD + +Primary intended usage in this repo: +- Auto mode (no stdin / no --lrc): loads lyrics from store notes. + A lyric note is stored under the note name 'lyric'. +- If the lyric note is missing, auto mode will attempt to auto-fetch synced lyrics + from a public API (LRCLIB) and store it into the 'lyric' note. + You can disable this by setting config key `lyric_autofetch` to false. +- You can still pipe LRC into this script (stdin) and it will render lyrics in mpv. + +Example (PowerShell): + Get-Content .\song.lrc | python -m plugins.mpv.lyric + +If you want to connect to a non-default mpv IPC server: + Get-Content .\song.lrc | python -m plugins.mpv.lyric --ipc "\\.\pipe\mpv-custom" +""" + +from __future__ import annotations + +import argparse +import bisect +import hashlib +import json +import os +import re +import sys +import tempfile +import time +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, TextIO +from urllib.parse import parse_qs, unquote, urlencode +from urllib.request import Request, urlopen +from urllib.parse import urlparse + +from plugins.mpv.mpv_ipc import MPV, MPVIPCClient + +_TIMESTAMP_RE = re.compile(r"\[(?P\d+):(?P\d{2})(?:\.(?P\d{1,3}))?\]") +_OFFSET_RE = re.compile(r"^\[offset:(?P[+-]?\d+)\]$", re.IGNORECASE) +_HASH_RE = re.compile(r"[0-9a-f]{64}", re.IGNORECASE) +_HYDRUS_HASH_QS_RE = re.compile(r"hash=([0-9a-f]{64})", re.IGNORECASE) + +_WIN_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]") +_WIN_UNC_RE = re.compile(r"^\\\\") + +_LOG_FH: Optional[TextIO] = None +_SINGLE_INSTANCE_LOCK_FH: Optional[TextIO] = None + +_LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible" + +# Optional overrides set by the playlist controller (.pipe/.mpv) so the lyric +# helper can resolve notes even when the local file path cannot be mapped back +# to a store via the store DB. +_ITEM_STORE_PROP = "user-data/medeia-item-store" +_ITEM_HASH_PROP = "user-data/medeia-item-hash" +_LEGACY_SUB_TRACK_TITLES = ("medeia-note-sub", "medeia-lyric-sub", "medeia-sub") + +# Note: We previously used `osd-overlay`, but some mpv builds return +# error='invalid parameter' for that command. We now use `show-text`, which is +# widely supported across mpv versions. + +_OSD_STYLE_SAVED: Optional[Dict[str, Any]] = None +_OSD_STYLE_APPLIED: bool = False +_NOTES_CACHE_VERSION = 1 +_DEFAULT_NOTES_CACHE_TTL_S = 900.0 +_DEFAULT_NOTES_CACHE_WAIT_S = 1.5 +_DEFAULT_NOTES_PENDING_WAIT_S = 12.0 +_SUBTITLE_NOTE_ALIASES = ("subtitle", "subtitles", "transcript", "transcription") + + +def _single_instance_lock_path(ipc_path: str) -> Path: + # Key the lock to the mpv IPC target so multiple mpv instances with different + # IPC servers can still run independent lyric helpers. + key = hashlib.sha1((ipc_path or "").encode("utf-8", errors="ignore")).hexdigest() + tmp_dir = Path(tempfile.gettempdir()) + return (tmp_dir / f"medeia-mpv-lyric-{key}.lock").resolve() + + +def _acquire_single_instance_lock(ipc_path: str) -> bool: + """Ensure only one MPV lyric process runs per IPC server. + + This prevents duplicate overlays (e.g. multiple lyric helpers racing to update OSD). + """ + global _SINGLE_INSTANCE_LOCK_FH + + if _SINGLE_INSTANCE_LOCK_FH is not None: + return True + + lock_path = _single_instance_lock_path(ipc_path) + lock_path.parent.mkdir(parents=True, exist_ok=True) + + try: + fh = open(lock_path, "a", encoding="utf-8", errors="replace") + except Exception: + # If we can't create the lock file, don't block playback; just proceed. + return True + + try: + if os.name == "nt": + import msvcrt + + # Lock the first byte (non-blocking). + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + _SINGLE_INSTANCE_LOCK_FH = fh + try: + fh.write(f"pid={os.getpid()} ipc={ipc_path}\n") + fh.flush() + except Exception: + pass + return True + except Exception: + try: + fh.close() + except Exception: + pass + return False + + +def _ass_escape(text: str) -> str: + # Escape braces/backslashes so lyric text can't break ASS formatting. + t = str(text or "") + t = t.replace("\\", "\\\\") + t = t.replace("{", "\\{") + t = t.replace("}", "\\}") + t = t.replace("\r\n", "\n").replace("\r", "\n") + t = t.replace("\n", "\\N") + return t + + +def _osd_set_text(client: MPVIPCClient, text: str, *, duration_ms: int = 1000) -> Optional[dict]: + # Signature: show-text [] [] + # Duration 0 clears immediately; we generally set it to cover until next update. + try: + d = int(duration_ms) + except Exception: + d = 1000 + if d < 0: + d = 0 + return client.send_command({ + "command": [ + "show-text", + str(text or ""), + d, + ] + }) + + +def _osd_clear(client: MPVIPCClient) -> None: + try: + _osd_set_text(client, "", duration_ms=0) + except Exception: + return + + +def _log(msg: str) -> None: + line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}" + try: + if _LOG_FH is not None: + _LOG_FH.write(line + "\n") + _LOG_FH.flush() + return + except Exception: + pass + + print(line, file=sys.stderr, flush=True) + + +def _ipc_get_property( + client: MPVIPCClient, + name: str, + default: object = None, + *, + raise_on_disconnect: bool = False, +) -> object: + try: + resp = client.send_command({ + "command": ["get_property", + name] + }) + except Exception as exc: + if raise_on_disconnect: + raise ConnectionError(f"Lost mpv IPC connection: {exc}") from exc + return default + if resp is None: + if raise_on_disconnect: + raise ConnectionError("Lost mpv IPC connection") + return default + if resp and resp.get("error") == "success": + return resp.get("data", default) + return default + + +def _ipc_set_property(client: MPVIPCClient, name: str, value: Any) -> bool: + resp = client.send_command({ + "command": ["set_property", + name, + value] + }) + return bool(resp and resp.get("error") == "success") + + +def _osd_capture_style(client: MPVIPCClient) -> Dict[str, Any]: + keys = [ + "osd-align-x", + "osd-align-y", + "osd-font-size", + "osd-margin-y", + ] + out: Dict[str, Any] = {} + for k in keys: + try: + out[k] = _ipc_get_property(client, k, None) + except Exception: + out[k] = None + return out + + +def _osd_apply_lyric_style(client: MPVIPCClient, *, config: Dict[str, Any]) -> None: + """Apply bottom-center + larger font for lyric show-text messages. + + This modifies mpv's global OSD settings, so we save and restore them. + """ + global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED + + if not _OSD_STYLE_APPLIED: + if _OSD_STYLE_SAVED is None: + _OSD_STYLE_SAVED = _osd_capture_style(client) + + try: + _ipc_set_property(client, "osd-align-x", "center") + _ipc_set_property(client, "osd-align-y", "bottom") + + scale = config.get("lyric_osd_font_scale", 1.15) + try: + scale_f = float(scale) + except Exception: + scale_f = 1.15 + if scale_f < 1.0: + scale_f = 1.0 + + old_size = None + try: + if _OSD_STYLE_SAVED is not None: + old_size = _OSD_STYLE_SAVED.get("osd-font-size") + except Exception: + old_size = None + if isinstance(old_size, (int, float)): + new_size = int(max(10, round(float(old_size) * scale_f))) + else: + # mpv default is typically ~55; choose a conservative readable size. + new_size = int(config.get("lyric_osd_font_size", 64)) + + _ipc_set_property(client, "osd-font-size", new_size) + + min_margin_y = int(config.get("lyric_osd_min_margin_y", 60)) + old_margin_y = None + try: + if _OSD_STYLE_SAVED is not None: + old_margin_y = _OSD_STYLE_SAVED.get("osd-margin-y") + except Exception: + old_margin_y = None + if isinstance(old_margin_y, (int, float)): + _ipc_set_property(client, "osd-margin-y", int(max(old_margin_y, min_margin_y))) + else: + _ipc_set_property(client, "osd-margin-y", min_margin_y) + except Exception: + return + + _OSD_STYLE_APPLIED = True + + +def _osd_restore_style(client: MPVIPCClient) -> None: + global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED + + if not _OSD_STYLE_APPLIED: + return + + try: + saved = _OSD_STYLE_SAVED or {} + for k, v in saved.items(): + if v is None: + continue + try: + _ipc_set_property(client, k, v) + except Exception: + pass + finally: + _OSD_STYLE_APPLIED = False + + +def _osd_clear_and_restore(client: MPVIPCClient) -> None: + """Clear OSD text and restore any saved OSD style in a single call.""" + _osd_clear(client) + _osd_restore_style(client) + + +def _http_get_json_raw(url: str, *, timeout_s: float = 10.0) -> Optional[Any]: + """HTTP GET and JSON-decode; returns the parsed value (dict, list, etc.) or None on any failure.""" + try: + req = Request( + url, + headers={ + "User-Agent": "medeia-macina/lyric", + "Accept": "application/json", + }, + method="GET", + ) + with urlopen(req, timeout=timeout_s) as resp: + data = resp.read() + import json + return json.loads(data.decode("utf-8", errors="replace")) + except Exception as exc: + _log(f"HTTP JSON failed: {exc} ({url})") + return None + + +def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]: + """HTTP GET returning a JSON object (dict), or None.""" + obj = _http_get_json_raw(url, timeout_s=timeout_s) + return obj if isinstance(obj, dict) else None + + +def _http_get_json_list(url: str, *, timeout_s: float = 10.0) -> Optional[list]: + """HTTP GET returning a JSON array (list), or None.""" + obj = _http_get_json_raw(url, timeout_s=timeout_s) + return obj if isinstance(obj, list) else None + + +def _sanitize_query(s: Optional[str]) -> Optional[str]: + if not isinstance(s, str): + return None + t = s.strip().strip("\ufeff") + return t if t else None + + +def _infer_artist_title_from_tags( + tags: List[str] +) -> tuple[Optional[str], + Optional[str]]: + artist = None + title = None + for t in tags or []: + ts = str(t) + low = ts.lower() + if low.startswith("artist:") and artist is None: + artist = ts.split(":", 1)[1].strip() or None + elif low.startswith("title:") and title is None: + title = ts.split(":", 1)[1].strip() or None + if artist and title: + break + return _sanitize_query(artist), _sanitize_query(title) + + +def _wrap_plain_lyrics_as_lrc(text: str) -> str: + # Fallback: create a crude LRC that advances every 4 seconds. + # This is intentionally simple and deterministic. + lines = [ln.strip() for ln in (text or "").splitlines()] + lines = [ln for ln in lines if ln] + if not lines: + return "" + out: List[str] = [] + t_s = 0 + for ln in lines: + mm = t_s // 60 + ss = t_s % 60 + out.append(f"[{mm:02d}:{ss:02d}.00]{ln}") + t_s += 4 + return "\n".join(out) + "\n" + + +def _fetch_lrclib( + *, + artist: Optional[str], + title: Optional[str], + duration_s: Optional[float] = None +) -> Optional[str]: + base = "https://lrclib.net/api" + + # Require both artist and title; title-only lookups cause frequent mismatches. + if not artist or not title: + return None + + # Try direct get. + q: Dict[str, + str] = { + "artist_name": artist, + "track_name": title, + } + if isinstance(duration_s, (int, float)) and duration_s and duration_s > 0: + q["duration"] = str(int(duration_s)) + url = f"{base}/get?{urlencode(q)}" + obj = _http_get_json(url) + if isinstance(obj, dict): + synced = obj.get("syncedLyrics") + if isinstance(synced, str) and synced.strip(): + _log("LRCLIB: got syncedLyrics") + return synced + plain = obj.get("plainLyrics") + if isinstance(plain, str) and plain.strip(): + _log("LRCLIB: only plainLyrics; wrapping") + wrapped = _wrap_plain_lyrics_as_lrc(plain) + return wrapped if wrapped.strip() else None + + # Fallback: search using artist+title only. + q_text = f"{artist} {title}" + url = f"{base}/search?{urlencode({'q': q_text})}" + items = _http_get_json_list(url) or [] + for item in items: + if not isinstance(item, dict): + continue + synced = item.get("syncedLyrics") + if isinstance(synced, str) and synced.strip(): + _log("LRCLIB: search hit with syncedLyrics") + return synced + # Plain lyrics fallback from search if available + for item in items: + if not isinstance(item, dict): + continue + plain = item.get("plainLyrics") + if isinstance(plain, str) and plain.strip(): + _log("LRCLIB: search hit only plainLyrics; wrapping") + wrapped = _wrap_plain_lyrics_as_lrc(plain) + return wrapped if wrapped.strip() else None + + return None + + +def _fetch_lyrics_ovh(*, artist: Optional[str], title: Optional[str]) -> Optional[str]: + # Public, no-auth lyrics provider (typically plain lyrics, not time-synced). + if not artist or not title: + return None + try: + # Endpoint uses path segments, so we urlencode each part. + from urllib.parse import quote + + url = f"https://api.lyrics.ovh/v1/{quote(artist)}/{quote(title)}" + obj = _http_get_json(url) + if not isinstance(obj, dict): + return None + lyr = obj.get("lyrics") + if isinstance(lyr, str) and lyr.strip(): + _log("lyrics.ovh: got plain lyrics; wrapping") + wrapped = _wrap_plain_lyrics_as_lrc(lyr) + return wrapped if wrapped.strip() else None + except Exception as exc: + _log(f"lyrics.ovh failed: {exc}") + return None + + +@dataclass(frozen=True) +class LrcLine: + time_s: float + text: str + + +def _frac_to_ms(frac: str) -> int: + # LRC commonly uses centiseconds (2 digits), but can be 1–3 digits. + if not frac: + return 0 + if len(frac) == 3: + return int(frac) + if len(frac) == 2: + return int(frac) * 10 + return int(frac) * 100 + + +def parse_lrc(text: str) -> List[LrcLine]: + """Parse LRC into sorted timestamped lines.""" + offset_ms = 0 + lines: List[LrcLine] = [] + + for raw_line in text.splitlines(): + line = raw_line.strip("\ufeff\r\n") + if not line: + continue + + # Optional global offset. + off_m = _OFFSET_RE.match(line) + if off_m: + try: + offset_ms = int(off_m.group("ms")) + except Exception: + offset_ms = 0 + continue + + matches = list(_TIMESTAMP_RE.finditer(line)) + if not matches: + # Ignore non-timestamp metadata lines like [ar:], [ti:], etc. + continue + + lyric_text = line[matches[-1].end():].strip() + for m in matches: + mm = int(m.group("m")) + ss = int(m.group("s")) + frac = m.group("frac") or "" + ts_ms = (mm * 60 + ss) * 1000 + _frac_to_ms(frac) + offset_ms + if ts_ms < 0: + continue + lines.append(LrcLine(time_s=ts_ms / 1000.0, text=lyric_text)) + + # Sort and de-dupe by timestamp (prefer last non-empty text). + lines.sort(key=lambda x: x.time_s) + deduped: List[LrcLine] = [] + for item in lines: + if deduped and abs(deduped[-1].time_s - item.time_s) < 1e-6: + if item.text: + deduped[-1] = item + else: + deduped.append(item) + return deduped + + +def _read_all_stdin() -> str: + return sys.stdin.read() + + +def _current_index(time_s: float, times: List[float]) -> int: + # Index of last timestamp <= time_s + return bisect.bisect_right(times, time_s) - 1 + + +def _lyric_duration_ms(idx: int, times: List[float], current_t: float) -> int: + """Duration in ms to display the lyric at *idx* — until the next timestamp or a safe maximum.""" + try: + if idx + 1 < len(times): + return int(max(250, min(8000, (times[idx + 1] - current_t) * 1000))) + except Exception: + pass + return 1200 + + +def _format_vtt_timestamp(seconds: float) -> str: + total_ms = max(0, int(round(float(seconds or 0.0) * 1000.0))) + hours = total_ms // 3600000 + minutes = (total_ms // 60000) % 60 + secs = (total_ms // 1000) % 60 + millis = total_ms % 1000 + return f"{hours:02d}:{minutes:02d}:{secs:02d}.{millis:03d}" + + +def _lrc_entries_to_vtt_text(entries: List[LrcLine]) -> str: + if not entries: + return "WEBVTT\n\n" + + lines: List[str] = ["WEBVTT", ""] + times = [entry.time_s for entry in entries] + for idx, entry in enumerate(entries, start=1): + start_s = max(0.0, float(entry.time_s or 0.0)) + if idx < len(entries): + end_s = max(start_s + 0.25, float(times[idx])) + else: + end_s = start_s + 1.2 + + text = str(entry.text or "").replace("\r\n", "\n").replace("\r", "\n") + cue_text = text if text.strip() else " " + + lines.append(str(idx)) + lines.append(f"{_format_vtt_timestamp(start_s)} --> {_format_vtt_timestamp(end_s)}") + lines.extend(cue_text.split("\n")) + lines.append("") + + return "\n".join(lines) + + +def _unwrap_memory_m3u(text: Optional[str]) -> Optional[str]: + """Extract the real target URL/path from a memory:// M3U payload.""" + if not isinstance(text, str) or not text.startswith("memory://"): + return text + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("#") or s.startswith("memory://"): + continue + return s + return text + + +def _extract_hash_from_target(target: str) -> Optional[str]: + if not isinstance(target, str): + return None + m = _HYDRUS_HASH_QS_RE.search(target) + if m: + return m.group(1).lower() + + # Fallback: plain hash string + s = target.strip().lower() + if _HASH_RE.fullmatch(s): + return s + return None + + +def _load_config_best_effort() -> dict: + try: + from SYS.config import load_config + + cfg = load_config() + + return cfg if isinstance(cfg, dict) else {} + except Exception: + return {} + + +def _cache_float_config(config: Optional[dict], key: str, default: float) -> float: + try: + raw = (config or {}).get(key) + if raw is None: + return float(default) + value = float(raw) + if value < 0: + return 0.0 + return value + except Exception: + return float(default) + + +def _notes_cache_root() -> Path: + root = Path(tempfile.gettempdir()) / "medeia-mpv-notes" / "cache" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _generated_sub_root() -> Path: + root = Path(tempfile.gettempdir()) / "medeia-mpv-notes" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _notes_cache_key(store: str, file_hash: str) -> str: + return hashlib.sha1( + f"{str(store or '').strip().lower()}:{str(file_hash or '').strip().lower()}".encode( + "utf-8", + errors="ignore", + ) + ).hexdigest() + + +def _notes_cache_path(store: str, file_hash: str) -> Path: + return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.json").resolve() + + +def _notes_pending_path(store: str, file_hash: str) -> Path: + return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.pending").resolve() + + +def _normalize_notes_payload(notes: Any) -> Dict[str, str]: + if not isinstance(notes, dict): + return {} + return { + str(k): str(v or "") + for k, v in notes.items() + if str(k).strip() + } + + +def load_cached_notes( + store: Optional[str], + file_hash: Optional[str], + *, + config: Optional[dict] = None, +) -> Optional[Dict[str, str]]: + if not store or not file_hash: + return None + + path = _notes_cache_path(str(store), str(file_hash)) + if not path.exists(): + return None + + ttl_s = _cache_float_config(config, "lyric_notes_cache_ttl_seconds", _DEFAULT_NOTES_CACHE_TTL_S) + if ttl_s > 0: + try: + age_s = max(0.0, time.time() - float(path.stat().st_mtime)) + if age_s > ttl_s: + return None + except Exception: + return None + + try: + payload = json.loads(path.read_text(encoding="utf-8", errors="replace")) + except Exception: + return None + + if not isinstance(payload, dict): + return None + if int(payload.get("version") or 0) != _NOTES_CACHE_VERSION: + return None + + return _normalize_notes_payload(payload.get("notes")) + + +def store_cached_notes( + store: Optional[str], + file_hash: Optional[str], + notes: Any, +) -> bool: + if not store or not file_hash: + return False + + normalized = _normalize_notes_payload(notes) + path = _notes_cache_path(str(store), str(file_hash)) + tmp_path = path.with_suffix(".tmp") + payload = { + "version": _NOTES_CACHE_VERSION, + "saved_at": time.time(), + "store": str(store), + "hash": str(file_hash), + "notes": normalized, + } + + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8", + errors="replace", + ) + tmp_path.replace(path) + return True + except Exception: + return False + + +def set_notes_prefetch_pending( + store: Optional[str], + file_hash: Optional[str], + pending: bool, +) -> None: + if not store or not file_hash: + return + + path = _notes_pending_path(str(store), str(file_hash)) + if pending: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(time.time()), encoding="utf-8", errors="replace") + except Exception: + return + return + + try: + if path.exists(): + path.unlink() + except Exception: + return + + +def is_notes_prefetch_pending( + store: Optional[str], + file_hash: Optional[str], + *, + stale_after_s: float = 60.0, +) -> bool: + if not store or not file_hash: + return False + + path = _notes_pending_path(str(store), str(file_hash)) + if not path.exists(): + return False + + try: + age_s = max(0.0, time.time() - float(path.stat().st_mtime)) + if stale_after_s > 0 and age_s > stale_after_s: + path.unlink(missing_ok=True) + return False + except Exception: + return False + + return True + + +def _infer_artist_title_from_mpv(client: MPVIPCClient) -> tuple[Optional[str], Optional[str]]: + artist = None + title = None + + artist_keys = [ + "metadata/by-key/artist", + "metadata/by-key/Artist", + "metadata/by-key/album_artist", + "metadata/by-key/ALBUMARTIST", + ] + title_keys = [ + "metadata/by-key/title", + "metadata/by-key/Title", + "media-title", + ] + + for key in artist_keys: + try: + value = _ipc_get_property(client, key, None) + except Exception: + value = None + artist = _sanitize_query(str(value) if isinstance(value, str) else None) + if artist: + break + + for key in title_keys: + try: + value = _ipc_get_property(client, key, None) + except Exception: + value = None + title = _sanitize_query(str(value) if isinstance(value, str) else None) + if title: + break + + return artist, title + + +def _extract_note_text(notes: Dict[str, str], name: str) -> Optional[str]: + """Return stripped text from the note named *name*, or None if absent or blank.""" + if not isinstance(notes, dict) or not notes: + return None + raw = None + for k, v in notes.items(): + if isinstance(k, str) and k.strip() == name: + raw = v + break + if not isinstance(raw, str): + return None + text = raw.strip("\ufeff\r\n") + return text if text.strip() else None + + +def _extract_first_note_text( + notes: Dict[str, str], + names: List[str], + *, + predicate: Optional[Any] = None, +) -> tuple[Optional[str], Optional[str]]: + for name in names: + candidate = _extract_note_text(notes, name) + if not candidate: + continue + if predicate is not None: + try: + if not bool(predicate(candidate)): + continue + except Exception: + continue + return name, candidate + return None, None + + +def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]: + """Return raw LRC text from the note named 'lyric'.""" + return _extract_note_text(notes, "lyric") + + +def _looks_like_subtitle_text(text: str) -> bool: + t = (text or "").lstrip("\ufeff\r\n").lstrip() + if not t: + return False + upper = t.upper() + if upper.startswith("WEBVTT"): + return True + if upper.startswith("[SCRIPT INFO]"): + return True + if "-->" in t: + return True + if re.search(r"(?m)^Dialogue:\s*", t): + return True + return False + + +def _extract_sub_from_notes(notes: Dict[str, str]) -> tuple[Optional[str], Optional[str]]: + """Return (note_name, subtitle_text) from note-backed subtitle/transcript keys.""" + primary = _extract_note_text(notes, "sub") + if primary: + return "sub", primary + return _extract_first_note_text( + notes, + list(_SUBTITLE_NOTE_ALIASES), + predicate=_looks_like_subtitle_text, + ) + + +def _display_note_name(note_name: Optional[str]) -> str: + text = re.sub(r"\s+", " ", str(note_name or "").replace("_", " ")).strip() + if not text: + return "subtitle" + lowered = text.casefold() + if lowered == "lyric": + return "lyrics" + if lowered == "sub": + return "subtitles" + return text + + +def _display_media_title(client: MPVIPCClient) -> Optional[str]: + for key in ("metadata/by-key/title", "metadata/by-key/Title", "media-title"): + try: + value = _ipc_get_property(client, key, None) + except Exception: + value = None + if isinstance(value, str): + text = re.sub(r"\s+", " ", value).strip() + if text: + return text + return None + + +def _generated_subtitle_title(client: MPVIPCClient, *, note_name: Optional[str]) -> str: + note_label = _display_note_name(note_name) + media_title = _display_media_title(client) + if media_title: + title = f"{note_label}: {media_title}" + else: + title = note_label + title = re.sub(r"\s+", " ", title).strip() + return title[:96] if len(title) > 96 else title + + +def _filename_slug(text: Optional[str], *, default: str) -> str: + value = re.sub(r"[^A-Za-z0-9._ -]+", " ", str(text or "")) + value = re.sub(r"\s+", "-", value).strip("- ._") + value = value[:48] + return value or default + + +def _infer_sub_extension(text: str) -> str: + # Best-effort: mpv generally understands SRT/VTT; choose based on content. + t = (text or "").lstrip("\ufeff\r\n").lstrip() + upper = t.upper() + if upper.startswith("WEBVTT"): + return ".vtt" + if upper.startswith("[SCRIPT INFO]") or re.search(r"(?m)^Dialogue:\s*", t): + return ".ass" + if "-->" in t: + # SRT typically uses commas for milliseconds, VTT uses dots. + if re.search(r"\d\d:\d\d:\d\d,\d\d\d\s*-->\s*\d\d:\d\d:\d\d,\d\d\d", t): + return ".srt" + return ".vtt" + return ".vtt" + + +def _write_temp_sub_file(*, key: str, text: str, label: Optional[str] = None) -> Path: + # Write to a content-addressed temp path so updates force mpv reload. + tmp_dir = _generated_sub_root() + + ext = _infer_sub_extension(text) + digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8", + errors="ignore") + ).hexdigest()[:16] + prefix = _filename_slug(label, default="subtitle") + path = (tmp_dir / f"{prefix}-{digest}{ext}").resolve() + path.write_text(text or "", encoding="utf-8", errors="replace") + return path + + +def _subtitle_track_snapshot(client: MPVIPCClient) -> List[Dict[str, Any]]: + raw = _ipc_get_property(client, "track-list", []) + return raw if isinstance(raw, list) else [] + + +def _track_external_sub_path(track: Dict[str, Any]) -> Optional[Path]: + if not isinstance(track, dict): + return None + for key in ("external-filename", "external_filename", "demux-filename", "demux_filename"): + raw = track.get(key) + if not isinstance(raw, str): + continue + text = raw.strip() + if not text: + continue + try: + return Path(text).expanduser().resolve() + except Exception: + return Path(text) + return None + + +def _is_medeia_generated_sub_track(track: Dict[str, Any]) -> bool: + if not isinstance(track, dict): + return False + title = str(track.get("title") or "").strip() + if title in _LEGACY_SUB_TRACK_TITLES: + return True + path = _track_external_sub_path(track) + if path is None: + return False + try: + path.relative_to(_generated_sub_root().resolve()) + return True + except Exception: + return False + + +def _find_medeia_sub_track_ids(client: MPVIPCClient) -> List[int]: + out: List[int] = [] + for track in _subtitle_track_snapshot(client): + if not isinstance(track, dict): + continue + if str(track.get("type") or "") != "sub": + continue + if not _is_medeia_generated_sub_track(track): + continue + try: + track_id = int(track.get("id")) + except Exception: + continue + out.append(track_id) + return out + + +def _log_medeia_sub_tracks(client: MPVIPCClient, reason: str) -> None: + parts: List[str] = [] + for track in _subtitle_track_snapshot(client): + if not isinstance(track, dict): + continue + if str(track.get("type") or "") != "sub": + continue + if not _is_medeia_generated_sub_track(track): + continue + title = str(track.get("title") or "").strip() + source = _track_external_sub_path(track) + parts.append( + f"id={track.get('id')}" + f" title={title!r}" + f" selected={bool(track.get('selected'))}" + f" external={bool(track.get('external'))}" + f" source={source.name if source is not None else ''}" + ) + if parts: + _log(f"Medeia subtitle tracks {reason}: " + " | ".join(parts)) + else: + _log(f"Medeia subtitle tracks {reason}: ") + + +def _remove_medeia_external_subs(client: MPVIPCClient, *, reason: str = "") -> None: + track_ids = _find_medeia_sub_track_ids(client) + if not track_ids: + return + _log(f"Removing Medeia subtitle tracks reason={reason or 'unknown'} ids={track_ids}") + for track_id in track_ids: + try: + client.send_command({ + "command": ["sub-remove", int(track_id)] + }) + except Exception: + continue + _log_medeia_sub_tracks(client, f"after-remove:{reason or 'unknown'}") + + +def _try_add_external_sub(client: MPVIPCClient, path: Path, *, title: str) -> None: + try: + client.send_command( + { + "command": ["sub-add", + str(path), + "select", + str(title or _NOTE_SUB_TRACK_TITLE)] + } + ) + except Exception: + return + + +def _is_stream_target(target: str) -> bool: + """Return True when mpv's 'path' is not a local filesystem file. + + We intentionally treat any URL/streaming scheme as invalid for lyrics in auto mode. + """ + if not isinstance(target, str): + return False + s = target.strip() + if not s: + return False + + # Windows local paths: drive letter or UNC. + if _WIN_DRIVE_RE.match(s) or _WIN_UNC_RE.match(s): + return False + + # Common streaming prefixes. + if s.startswith("http://") or s.startswith("https://"): + return True + + # Generic scheme:// (e.g. ytdl://, edl://, rtmp://, etc.). + if "://" in s: + try: + parsed = urlparse(s) + scheme = (parsed.scheme or "").lower() + if scheme and scheme not in {"file"}: + return True + except Exception: + return True + + return False + + +def _normalize_file_uri_target(target: str) -> str: + """Convert file:// URIs to a local filesystem path string when possible.""" + if not isinstance(target, str): + return target + s = target.strip() + if not s: + return target + if not s.lower().startswith("file://"): + return target + + try: + parsed = urlparse(s) + path = unquote(parsed.path or "") + + if os.name == "nt": + # UNC: file://server/share/path -> \\server\share\path + if parsed.netloc: + p = path.replace("/", "\\") + if p.startswith("\\"): + p = p.lstrip("\\") + return f"\\\\{parsed.netloc}\\{p}" if p else f"\\\\{parsed.netloc}" + + # Drive letter: file:///C:/path -> C:/path + if path.startswith("/") and len(path) >= 3 and path[2] == ":": + path = path[1:] + + return path or target + except Exception: + return target + + +def _extract_store_from_url_target(target: str) -> Optional[str]: + """Extract explicit store name from a URL query param `store=...` (if present).""" + if not isinstance(target, str): + return None + s = target.strip() + if not (s.startswith("http://") or s.startswith("https://")): + return None + try: + parsed = urlparse(s) + if not parsed.query: + return None + qs = parse_qs(parsed.query) + raw = qs.get("store", [None])[0] + if isinstance(raw, str) and raw.strip(): + return raw.strip() + except Exception: + return None + return None + + +def _infer_hydrus_store_from_url_target(*, target: str, config: dict) -> Optional[str]: + """Infer a Hydrus store backend by matching the URL prefix to the backend base URL.""" + if not isinstance(target, str): + return None + s = target.strip() + if not (s.startswith("http://") or s.startswith("https://")): + return None + + try: + from Store import Store as StoreRegistry + + reg = StoreRegistry(config, suppress_debug=True) + backends = [(name, reg[name]) for name in reg.list_backends()] + except Exception: + return None + + matches: List[str] = [] + for name, backend in backends: + backend_type = str(getattr(backend, "STORE_TYPE", "") or "").strip().lower() + backend_class = type(backend).__name__.strip().lower() + is_hydrus_backend = backend_type == "hydrusnetwork" or backend_class == "hydrusnetwork" + if not is_hydrus_backend: + try: + from ProviderCore.registry import get_plugin + + hydrus_provider = get_plugin("hydrusnetwork", config) + checker = getattr(hydrus_provider, "is_backend", None) if hydrus_provider is not None else None + if callable(checker): + is_hydrus_backend = bool(checker(backend, name)) + except Exception: + is_hydrus_backend = False + if not is_hydrus_backend: + continue + base_url = getattr(backend, "_url", None) + if not base_url: + client = getattr(backend, "_client", None) + base_url = getattr(client, "url", None) if client else None + if not base_url: + continue + base = str(base_url).rstrip("/") + if s.startswith(base): + matches.append(name) + + if len(matches) == 1: + return matches[0] + return None + + +def _resolve_store_backend_for_target( + *, + target: str, + file_hash: str, + config: dict, +) -> tuple[Optional[str], + Any]: + """Resolve a store backend for a local mpv target using the store DB. + + A target is considered valid only when: + - target is a local filesystem file + - a backend's get_file(hash) returns a local file path + - that path resolves to the same target path + """ + try: + p = Path(target) + if not p.exists() or not p.is_file(): + return None, None + target_resolved = p.resolve() + except Exception: + return None, None + + try: + from Store import Store as StoreRegistry + + reg = StoreRegistry(config, suppress_debug=True) + backend_names = list(reg.list_backends()) + except Exception: + return None, None + + + + for name in backend_names: + try: + backend = reg[name] + except Exception: + continue + + store_file = None + try: + store_file = backend.get_file(file_hash, config=config) + except TypeError: + try: + store_file = backend.get_file(file_hash) + except Exception: + store_file = None + except Exception: + store_file = None + + if not store_file: + continue + + # Only accept local files; if the backend returns a URL, it's not valid for lyrics. + try: + store_path = Path(str(store_file)).expanduser() + if not store_path.exists() or not store_path.is_file(): + continue + if store_path.resolve() != target_resolved: + continue + except Exception: + continue + + return name, backend + + return None, None + + +def _infer_hash_for_target(target: str) -> Optional[str]: + """Infer SHA256 hash from Hydrus URL query, hash-named local files, or by hashing local file content.""" + h = _extract_hash_from_target(target) + if h: + return h + + try: + p = Path(target) + if not p.exists() or not p.is_file(): + return None + stem = p.stem + if isinstance(stem, str) and _HASH_RE.fullmatch(stem.strip()): + return stem.strip().lower() + from SYS.utils import sha256_file + + return sha256_file(p) + except Exception: + return None + + +@dataclass +class _PlaybackState: + """Mutable per-track resolution state for the auto overlay loop. + + Centralising these variables in one object eliminates the repeated + 15-line 'reset everything + clear OSD + remove sub' block that + previously appeared five times inside run_auto_overlay. + """ + + store_name: Optional[str] = None + file_hash: Optional[str] = None + key: Optional[str] = None + backend: Optional[Any] = None + entries: List[LrcLine] = field(default_factory=list) + times: List[float] = field(default_factory=list) + loaded_key: Optional[str] = None + loaded_mode: Optional[str] = None # 'lyric' | 'sub' | 'lyric-sub' | None + loaded_sub_path: Optional[Path] = None + last_target: Optional[str] = None + fetch_attempt_key: Optional[str] = None + fetch_attempt_at: float = 0.0 + cache_wait_key: Optional[str] = None + cache_wait_started_at: float = 0.0 + cache_wait_next_probe_at: float = 0.0 + + def clear(self, client: MPVIPCClient, *, clear_hash: bool = True) -> None: + """Reset backend resolution and clean up any active OSD / external subtitle. + + Pass ``clear_hash=False`` to preserve *file_hash* when the hash is + still valid but the store lookup failed (e.g. store temporarily + unavailable), so the late-arriving-context fallback can retry later. + """ + self.store_name = None + self.backend = None + self.key = None + if clear_hash: + self.file_hash = None + self.entries = [] + self.times = [] + self.cache_wait_key = None + self.cache_wait_started_at = 0.0 + self.cache_wait_next_probe_at = 0.0 + if self.loaded_key is not None: + _osd_clear_and_restore(client) + self.loaded_key = None + self.loaded_mode = None + if self.loaded_sub_path is not None: + _remove_medeia_external_subs(client, reason="state-clear") + self.loaded_sub_path = None + + +def run_auto_overlay( + *, + mpv: MPV, + poll_s: float = 0.15, + config: Optional[dict] = None +) -> int: + """Auto mode: track mpv's current file and render lyrics (note: 'lyric') or subtitles (note: 'sub'). + + State is managed via :class:`_PlaybackState` to eliminate the repeated + 15-line reset blocks of the previous implementation. + """ + cfg = config or {} + + client = mpv.client() + if not client.connect(): + _log("mpv IPC is not reachable (is mpv running with --input-ipc-server?).") + return 3 + + _log(f"Auto overlay connected (ipc={getattr(mpv, 'ipc_path', None)})") + _remove_medeia_external_subs(client, reason="startup-sweep") + + state = _PlaybackState() + last_idx: Optional[int] = None + last_text: Optional[str] = None + last_visible: Optional[bool] = None + + global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED + + # Import the Store registry once so each track change doesn't re-import the module. + try: + from Store import Store as _StoreRegistry # noqa: PLC0415 + _store_cls: Any = _StoreRegistry + except Exception: + _store_cls = None + + def _make_registry() -> Optional[Any]: + if _store_cls is None: + return None + try: + return _store_cls(cfg, suppress_debug=True) + except Exception: + return None + + while True: + # ---------------------------------------------------------------- + # 1. Read IPC properties; reconnect on disconnect. + # ---------------------------------------------------------------- + try: + visible_raw = _ipc_get_property( + client, _LYRIC_VISIBLE_PROP, True, raise_on_disconnect=True + ) + raw_path = _ipc_get_property(client, "path", None, raise_on_disconnect=True) + except ConnectionError: + _osd_clear_and_restore(client) + try: + client.disconnect() + except Exception: + pass + _OSD_STYLE_SAVED = None + _OSD_STYLE_APPLIED = False + if not client.connect(): + _log("mpv IPC disconnected; exiting lyric helper") + return 4 + _remove_medeia_external_subs(client, reason="reconnect-sweep:path") + state.clear(client) + state.last_target = None + last_idx = None + last_text = None + last_visible = None + time.sleep(poll_s) + continue + + # ---------------------------------------------------------------- + # 2. Visibility toggle support. + # ---------------------------------------------------------------- + visible = bool(visible_raw) if isinstance(visible_raw, (bool, int)) else True + if last_visible is None: + last_visible = visible + elif last_visible is True and visible is False: + _osd_clear_and_restore(client) + _remove_medeia_external_subs(client, reason="visibility-off") + state.loaded_sub_path = None + last_idx = None + last_text = None + last_visible = visible + elif last_visible is False and visible is True: + if state.loaded_mode in {"sub", "lyric-sub"} and state.loaded_sub_path is None: + state.loaded_key = None + last_idx = None + last_text = None + last_visible = visible + else: + last_visible = visible + + # ---------------------------------------------------------------- + # 3. Normalise the current playback target. + # ---------------------------------------------------------------- + target = _unwrap_memory_m3u(str(raw_path)) if isinstance(raw_path, str) else None + if isinstance(target, str): + target = _normalize_file_uri_target(target) + + if not isinstance(target, str) or not target: + time.sleep(poll_s) + continue + + is_http = target.startswith("http://") or target.startswith("https://") + + # Non-HTTP streams (ytdl://, edl://, rtmp://, etc.) are never valid for lyrics. + if (not is_http) and _is_stream_target(target): + state.clear(client) + state.last_target = target + time.sleep(poll_s) + continue + + # ---------------------------------------------------------------- + # 4. Read user-data overrides from the playlist controller. + # ---------------------------------------------------------------- + store_override: Optional[str] = None + hash_override: Optional[str] = None + try: + raw_so = _ipc_get_property(client, _ITEM_STORE_PROP, None) + raw_ho = _ipc_get_property(client, _ITEM_HASH_PROP, None) + store_override = str(raw_so).strip() if raw_so else None + hash_override = str(raw_ho).strip().lower() if raw_ho else None + except Exception: + pass + + # ---------------------------------------------------------------- + # 5. Resolve store / hash on target change. + # ---------------------------------------------------------------- + if target != state.last_target: + state.last_target = target + last_idx = None + last_text = None + + _log(f"Target changed: {target}") + + state.file_hash = _infer_hash_for_target(target) + if not state.file_hash: + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + # Reset backend state; user-data override may supply it right away. + state.store_name = None + state.backend = None + state.key = None + state.cache_wait_key = None + state.cache_wait_started_at = 0.0 + state.cache_wait_next_probe_at = 0.0 + + if store_override and (not hash_override or hash_override == state.file_hash): + reg = _make_registry() + if reg is not None: + try: + state.backend = reg[store_override] + state.store_name = store_override + state.key = f"{state.store_name}:{state.file_hash}" + _log( + f"Resolved via mpv override" + f" store={state.store_name!r} hash={state.file_hash!r} valid=True" + ) + except Exception: + state.backend = None + state.store_name = None + state.key = None + + if is_http: + store_from_url = _extract_store_from_url_target(target) + store_name = store_from_url or _infer_hydrus_store_from_url_target( + target=target, config=cfg + ) + if not store_name: + _log("HTTP target has no store mapping; lyrics disabled") + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + reg = _make_registry() + if reg is None: + _log(f"HTTP target store {store_name!r} not available; lyrics disabled") + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + try: + state.backend = reg[store_name] + state.store_name = store_name + except Exception: + _log(f"HTTP target store {store_name!r} not available; lyrics disabled") + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + # Existence check only when store was inferred (not explicit in ?store=…). + # When ?store= is in the URL mpv is already streaming — the file provably exists. + if not store_from_url: + try: + meta = state.backend.get_metadata(state.file_hash, config=cfg) + except Exception: + meta = None + if meta is None: + _log( + f"HTTP target not found in store DB" + f" (store={store_name!r} hash={state.file_hash}); lyrics disabled" + ) + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + state.key = f"{state.store_name}:{state.file_hash}" + _log(f"Resolved store={state.store_name!r} hash={state.file_hash!r} valid=True") + + else: + # Local file: resolve via store DB (skip if user-data already resolved it). + if not state.key or not state.backend: + state.store_name, state.backend = _resolve_store_backend_for_target( + target=target, + file_hash=state.file_hash, + config=cfg, + ) + state.key = ( + f"{state.store_name}:{state.file_hash}" + if state.store_name and state.file_hash else None + ) + + _log( + f"Resolved store={state.store_name!r} hash={state.file_hash!r}" + f" valid={bool(state.key)}" + ) + + if not state.key or not state.backend: + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + # ---------------------------------------------------------------- + # 6. Late-arriving context fallback: user-data override published + # after the track change was already processed without a backend. + # ---------------------------------------------------------------- + if (not is_http) and target and (not state.key or not state.backend): + try: + state.file_hash = _infer_hash_for_target(target) or state.file_hash + except Exception: + pass + + if ( + store_override + and state.file_hash + and (not hash_override or hash_override == state.file_hash) + ): + reg = _make_registry() + if reg is not None: + try: + state.backend = reg[store_override] + state.store_name = store_override + state.key = f"{state.store_name}:{state.file_hash}" + _log( + f"Resolved via mpv override" + f" store={state.store_name!r} hash={state.file_hash!r} valid=True" + ) + except Exception: + pass + + # ---------------------------------------------------------------- + # 7. Load / reload content when the resolved key changes. + # ---------------------------------------------------------------- + if ( + state.key + and state.key != state.loaded_key + and state.store_name + and state.file_hash + and state.backend + ): + notes: Optional[Dict[str, str]] = None + cache_wait_s = _cache_float_config( + cfg, + "lyric_notes_cache_wait_seconds", + _DEFAULT_NOTES_CACHE_WAIT_S, + ) + pending_wait_s = _cache_float_config( + cfg, + "lyric_notes_pending_wait_seconds", + _DEFAULT_NOTES_PENDING_WAIT_S, + ) + + try: + notes = load_cached_notes(state.store_name, state.file_hash, config=cfg) + except Exception: + notes = None + + if notes is None: + now = time.time() + if state.cache_wait_key != state.key: + state.cache_wait_key = state.key + state.cache_wait_started_at = now + state.cache_wait_next_probe_at = 0.0 + elif state.cache_wait_next_probe_at > now: + time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) + continue + pending = is_notes_prefetch_pending(state.store_name, state.file_hash) + waited_s = max(0.0, now - float(state.cache_wait_started_at or now)) + + if pending and waited_s < pending_wait_s: + state.cache_wait_next_probe_at = now + max(0.2, min(0.5, pending_wait_s - waited_s)) + time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) + continue + + if waited_s < cache_wait_s: + state.cache_wait_next_probe_at = now + max(0.2, min(0.5, cache_wait_s - waited_s)) + time.sleep(max(0.05, min(0.5, state.cache_wait_next_probe_at - now))) + continue + + try: + notes = state.backend.get_note(state.file_hash, config=cfg) or {} + except Exception: + notes = {} + + try: + store_cached_notes(state.store_name, state.file_hash, notes) + except Exception: + pass + + state.cache_wait_key = None + state.cache_wait_started_at = 0.0 + state.cache_wait_next_probe_at = 0.0 + + try: + _log( + f"Loaded notes keys:" + f" {sorted(str(k) for k in notes) if isinstance(notes, dict) else 'N/A'}" + ) + except Exception: + _log("Loaded notes keys: ") + + sub_note_name, sub_text = _extract_sub_from_notes(notes) + if sub_text: + # Hand subtitles to mpv's track subsystem; suppress OSD lyric overlay. + _osd_clear_and_restore(client) + sub_path: Optional[Path] = None + sub_title = _generated_subtitle_title(client, note_name=sub_note_name) + try: + sub_path = _write_temp_sub_file(key=state.key, text=sub_text, label=sub_title) + except Exception as exc: + _log(f"Failed to write sub note temp file: {exc}") + + if sub_path is not None: + _remove_medeia_external_subs(client, reason="load-note-sub") + _try_add_external_sub(client, sub_path, title=sub_title) + state.loaded_sub_path = sub_path + _log( + f"Loaded note-backed native subtitle track" + f" note={sub_note_name!r} title={sub_title!r} path={sub_path}" + ) + _log_medeia_sub_tracks(client, "after-add-note-sub") + + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = "sub" + + else: + # Switching away from native subtitle mode: unload the external subtitle. + if state.loaded_sub_path is not None: + _remove_medeia_external_subs(client, reason="switch-away-native-sub") + state.loaded_sub_path = None + + lrc_text = _extract_lrc_from_notes(notes) + if not lrc_text: + _log("No lyric note found (note name: 'lyric')") + + # Auto-fetch: throttled per key to avoid hammering APIs. + autofetch_enabled = bool(cfg.get("lyric_autofetch", True)) + now = time.time() + if ( + not lrc_text + and + autofetch_enabled + and state.key != state.fetch_attempt_key + and (now - state.fetch_attempt_at) > 2.0 + ): + state.fetch_attempt_key = state.key + state.fetch_attempt_at = now + + artist, title = _infer_artist_title_from_mpv(client) + duration_s: Optional[float] = None + try: + duration_s = _ipc_get_property(client, "duration", None) + except Exception: + pass + if not artist or not title: + try: + tags, _src = state.backend.get_tag(state.file_hash, config=cfg) + if isinstance(tags, list): + artist, title = _infer_artist_title_from_tags( + [str(x) for x in tags] + ) + except Exception: + pass + + _log( + f"Autofetch query artist={artist!r} title={title!r}" + f" duration={duration_s!r}" + ) + + if not artist or not title: + _log("Autofetch skipped: requires both artist and title") + fetched: Optional[str] = None + else: + fetched = _fetch_lrclib( + artist=artist, + title=title, + duration_s=( + float(duration_s) + if isinstance(duration_s, (int, float)) else None + ), + ) + if not fetched or not fetched.strip(): + fetched = _fetch_lyrics_ovh(artist=artist, title=title) + + if fetched and fetched.strip(): + try: + ok = bool( + state.backend.set_note( + state.file_hash, "lyric", fetched, config=cfg + ) + ) + _log(f"Autofetch stored lyric note ok={ok}") + except Exception as exc: + _log(f"Autofetch failed to store lyric note: {exc}") + else: + _log("Autofetch: no lyrics found") + + state.entries = [] + state.times = [] + _osd_clear_and_restore(client) + state.loaded_key = None + state.loaded_mode = None + + elif not lrc_text: + # No lyric and autofetch is throttled or disabled for this key. + _osd_clear_and_restore(client) + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = None + + else: + _log(f"Loaded lyric note ({len(lrc_text)} chars)") + parsed = parse_lrc(lrc_text) + if not parsed: + _log("Lyric note contained no timestamped entries") + _osd_clear_and_restore(client) + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = None + else: + lyric_sub_path: Optional[Path] = None + lyric_sub_title = _generated_subtitle_title(client, note_name="lyric") + try: + lyric_sub_text = _lrc_entries_to_vtt_text(parsed) + lyric_sub_path = _write_temp_sub_file( + key=f"{state.key}:lyric", + text=lyric_sub_text, + label=lyric_sub_title, + ) + except Exception as exc: + _log(f"Failed to write lyric note temp subtitle: {exc}") + + if lyric_sub_path is None: + _osd_clear_and_restore(client) + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = None + else: + _osd_clear_and_restore(client) + _remove_medeia_external_subs(client, reason="load-lyric-sub") + _try_add_external_sub(client, lyric_sub_path, title=lyric_sub_title) + state.loaded_sub_path = lyric_sub_path + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = "lyric-sub" + _log( + f"Loaded lyric note as native subtitle track" + f" title={lyric_sub_title!r} entries={len(parsed)}" + f" path={lyric_sub_path}" + ) + _log_medeia_sub_tracks(client, "after-add-lyric-sub") + + # ---------------------------------------------------------------- + # 8. Render the current lyric line. + # ---------------------------------------------------------------- + try: + t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) + except ConnectionError: + _osd_clear_and_restore(client) + try: + client.disconnect() + except Exception: + pass + _OSD_STYLE_SAVED = None + _OSD_STYLE_APPLIED = False + if not client.connect(): + _log("mpv IPC disconnected; exiting lyric helper") + return 4 + _remove_medeia_external_subs(client, reason="reconnect-sweep:time") + state.clear(client) + state.last_target = None + last_idx = None + last_text = None + last_visible = None + time.sleep(poll_s) + continue + + if not isinstance(t, (int, float)): + time.sleep(poll_s) + continue + + if not state.entries: + if last_text is not None: + _osd_clear_and_restore(client) + last_text = None + last_idx = None + time.sleep(poll_s) + continue + + if not visible: + if last_text is not None: + _osd_clear_and_restore(client) + last_text = None + last_idx = None + time.sleep(poll_s) + continue + + idx = _current_index(float(t), state.times) + if idx < 0: + time.sleep(poll_s) + continue + + line = state.entries[idx] + if idx != last_idx or line.text != last_text: + if state.loaded_mode == "lyric": + try: + _osd_apply_lyric_style(client, config=cfg) + except Exception: + pass + + dur_ms = _lyric_duration_ms(idx, state.times, float(t)) + resp = _osd_set_text(client, line.text, duration_ms=dur_ms) + if resp is None: + client.disconnect() + if not client.connect(): + print("Lost mpv IPC connection.", file=sys.stderr) + return 4 + elif isinstance(resp, dict) and resp.get("error") not in (None, "success"): + _log(f"mpv show-text returned error={resp.get('error')!r}") + last_idx = idx + last_text = line.text + + time.sleep(poll_s) + +def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> int: + if not entries: + print("No timestamped LRC lines found.", file=sys.stderr) + return 2 + + times = [e.time_s for e in entries] + last_idx: Optional[int] = None + last_text: Optional[str] = None + + client = mpv.client() + if not client.connect(): + print( + "mpv IPC is not reachable (is mpv running with --input-ipc-server?).", + file=sys.stderr, + ) + return 3 + + while True: + try: + # mpv returns None when idle/no file. + t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) + except ConnectionError: + _osd_clear(client) + try: + client.disconnect() + except Exception: + pass + if not client.connect(): + print("Lost mpv IPC connection.", file=sys.stderr) + return 4 + time.sleep(poll_s) + continue + + if not isinstance(t, (int, float)): + time.sleep(poll_s) + continue + + idx = _current_index(float(t), times) + if idx < 0: + # Before first lyric timestamp. + time.sleep(poll_s) + continue + + line = entries[idx] + if idx != last_idx or line.text != last_text: + dur_ms = _lyric_duration_ms(idx, times, float(t)) + resp = _osd_set_text(client, line.text, duration_ms=dur_ms) + if resp is None: + client.disconnect() + if not client.connect(): + print("Lost mpv IPC connection.", file=sys.stderr) + return 4 + elif isinstance(resp, dict) and resp.get("error") not in (None, "success"): + _log(f"mpv show-text returned error={resp.get('error')!r}") + last_idx = idx + last_text = line.text + + time.sleep(poll_s) + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(prog="python -m plugins.mpv.lyric", add_help=True) + parser.add_argument( + "--ipc", + default=None, + help="mpv IPC path. Defaults to the repo's fixed IPC pipe name.", + ) + parser.add_argument( + "--lrc", + default=None, + help="Path to an .lrc file. If omitted, reads LRC from stdin.", + ) + parser.add_argument( + "--poll", + type=float, + default=0.15, + help="Polling interval in seconds for time-pos updates.", + ) + parser.add_argument( + "--log", + default=None, + help="Optional path to a log file for diagnostics.", + ) + + args = parser.parse_args(argv) + + # Configure logging early. + global _LOG_FH + if args.log: + try: + log_path = Path(str(args.log)).expanduser().resolve() + log_path.parent.mkdir(parents=True, exist_ok=True) + _LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace") + _log("plugins.mpv.lyric starting") + except Exception: + _LOG_FH = None + + mpv = MPV(ipc_path=args.ipc) if args.ipc else MPV() + + # Prevent multiple lyric helpers from running at once for the same mpv IPC. + if not _acquire_single_instance_lock(getattr(mpv, "ipc_path", "") or ""): + _log("Another lyric helper instance is already running for this IPC; exiting.") + return 0 + + # If --lrc is provided, use it. + if args.lrc: + with open(args.lrc, "r", encoding="utf-8", errors="replace") as f: + lrc_text = f.read() + entries = parse_lrc(lrc_text) + try: + return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll)) + except KeyboardInterrupt: + return 0 + + # Otherwise: if stdin has content, treat it as LRC; if stdin is empty/TTY, auto-discover. + lrc_text = "" + try: + if not sys.stdin.isatty(): + lrc_text = _read_all_stdin() or "" + except Exception: + lrc_text = "" + + if lrc_text.strip(): + entries = parse_lrc(lrc_text) + try: + return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll)) + except KeyboardInterrupt: + return 0 + + cfg = _load_config_best_effort() + try: + return run_auto_overlay(mpv=mpv, poll_s=float(args.poll), config=cfg) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/mpv/mpv_ipc.py b/plugins/mpv/mpv_ipc.py new file mode 100644 index 0000000..d50a7d7 --- /dev/null +++ b/plugins/mpv/mpv_ipc.py @@ -0,0 +1,1243 @@ +"""MPV IPC client for cross-platform communication. + +This module provides a cross-platform interface to communicate with mpv +using either named pipes (Windows) or Unix domain sockets (Linux/macOS). + +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 +import socket +import subprocess +import sys +import time as _time +import shutil +from pathlib import Path +from typing import Any, Dict, Optional, List, BinaryIO, Tuple, cast + +from SYS.logger import debug + +# Fixed pipe name for persistent MPV connection across all Python sessions +FIXED_IPC_PIPE_NAME = "mpv-medios-macina" +MPV_LUA_SCRIPT_PATH = str(Path(__file__).resolve().parent / "LUA" / "main.lua") + +_LYRIC_PROCESS: Optional[subprocess.Popen] = None +_LYRIC_LOG_FH: Optional[Any] = None + +_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None + + +def _repo_root() -> Path: + package_dir = Path(__file__).resolve().parent + if package_dir.name.lower() == "mpv" and package_dir.parent.name.lower() == "plugins": + return package_dir.parent.parent + return package_dir.parent + + +def _package_root() -> Path: + return Path(__file__).resolve().parent + + +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_pipe_bytes_available(pipe: BinaryIO) -> Optional[int]: + """Return the number of bytes ready to read from a Windows named pipe.""" + if platform.system() != "Windows": + return None + try: + import msvcrt + + handle = msvcrt.get_osfhandle(pipe.fileno()) + kernel32 = ctypes.windll.kernel32 + PeekNamedPipe = kernel32.PeekNamedPipe + PeekNamedPipe.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_uint32), + ctypes.c_void_p, + ] + PeekNamedPipe.restype = ctypes.c_bool + + total_available = ctypes.c_uint32(0) + ok = PeekNamedPipe( + ctypes.c_void_p(handle), + None, + 0, + None, + ctypes.byref(total_available), + None, + ) + if not ok: + return None + return int(total_available.value) + except Exception: + return None + + +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": + return python_exe + try: + exe = str(python_exe or "").strip() + except Exception: + exe = "" + if not exe: + return None + low = exe.lower() + if low.endswith("pythonw.exe"): + return exe + if low.endswith("python.exe"): + try: + candidate = exe[:-10] + "pythonw.exe" + if os.path.exists(candidate): + return candidate + except Exception: + pass + return exe + + +def _windows_hidden_subprocess_kwargs() -> Dict[str, Any]: + """Best-effort kwargs to avoid flashing console windows on Windows. + + Applies to subprocess.run/check_output/Popen. + """ + if platform.system() != "Windows": + return {} + + kwargs: Dict[str, + Any] = {} + try: + create_no_window = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + kwargs["creationflags"] = int(create_no_window) + except Exception: + pass + + # Also set startupinfo to hidden, for APIs that honor it. + try: + si = subprocess.STARTUPINFO() + si.dwFlags |= subprocess.STARTF_USESHOWWINDOW + si.wShowWindow = subprocess.SW_HIDE + kwargs["startupinfo"] = si + except Exception: + pass + + return kwargs + + +def _windows_list_mpv_pids(ipc_path: str) -> List[int]: + """Return PIDs of mpv.exe processes configured for the given IPC pipe.""" + if platform.system() != "Windows": + return [] + try: + ipc_path = str(ipc_path or "").strip() + except Exception: + ipc_path = "" + if not ipc_path: + return [] + + ps_script = ( + "$ipc = " + json.dumps(ipc_path) + "; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.Name -match '^mpv(\\.exe)?$' -and $_.CommandLine -and $_.CommandLine -match ('--input-ipc-server=' + [regex]::Escape($ipc)) } | " + "Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress" + ) + + try: + out = subprocess.check_output( + ["powershell", "-NoProfile", "-Command", ps_script], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + text=True, + **_windows_hidden_subprocess_kwargs(), + ) + except Exception: + return [] + + txt = (out or "").strip() + if not txt or txt == "null": + return [] + + try: + obj = json.loads(txt) + except Exception: + return [] + + raw = obj if isinstance(obj, list) else [obj] + pids: List[int] = [] + for value in raw: + try: + pid = int(value) + except Exception: + continue + if pid > 0 and pid not in pids: + pids.append(pid) + return pids + + +def _check_mpv_availability() -> Tuple[bool, Optional[str]]: + """Return (available, reason) for the mpv executable. + + This checks that: + - `mpv` is present in PATH + - `mpv --version` can run successfully + + Result is cached per-process to avoid repeated subprocess calls. + """ + global _MPV_AVAILABILITY_CACHE + if _MPV_AVAILABILITY_CACHE is not None: + return _MPV_AVAILABILITY_CACHE + + mpv_path = shutil.which("mpv") + if not mpv_path: + _MPV_AVAILABILITY_CACHE = (False, "Executable 'mpv' not found in PATH") + return _MPV_AVAILABILITY_CACHE + + try: + result = subprocess.run( + [mpv_path, + "--version"], + capture_output=True, + text=True, + timeout=2, + **_windows_hidden_subprocess_kwargs(), + ) + if result.returncode == 0: + _MPV_AVAILABILITY_CACHE = (True, None) + return _MPV_AVAILABILITY_CACHE + _MPV_AVAILABILITY_CACHE = ( + False, + f"MPV returned non-zero exit code: {result.returncode}" + ) + return _MPV_AVAILABILITY_CACHE + except Exception as exc: + _MPV_AVAILABILITY_CACHE = (False, f"Error running MPV: {exc}") + return _MPV_AVAILABILITY_CACHE + + +def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]: + """Return PIDs of `python -m plugins.mpv.lyric --ipc ` helpers (Windows only).""" + if platform.system() != "Windows": + return [] + try: + ipc_path = str(ipc_path or "") + except Exception: + ipc_path = "" + if not ipc_path: + return [] + + # Use CIM to query command lines; output as JSON for robust parsing. + # Note: `ConvertTo-Json` returns a number for single item, array for many, or null. + ps_script = ( + "$ipc = " + json.dumps(ipc_path) + "; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.CommandLine -and $_.CommandLine -match ' -m\\s+plugins\\.mpv\\.lyric(\\s|$)' -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | " + "Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress" + ) + + try: + out = subprocess.check_output( + ["powershell", + "-NoProfile", + "-Command", + ps_script], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + text=True, + **_windows_hidden_subprocess_kwargs(), + ) + except Exception: + return [] + + txt = (out or "").strip() + if not txt or txt == "null": + return [] + try: + obj = json.loads(txt) + except Exception: + return [] + + pids: List[int] = [] + if isinstance(obj, list): + for v in obj: + try: + pids.append(int(v)) + except Exception: + pass + else: + try: + pids.append(int(obj)) + except Exception: + pass + + # De-dupe and filter obvious junk. + uniq: List[int] = [] + for pid in pids: + if pid and pid > 0 and pid not in uniq: + uniq.append(pid) + return uniq + + +def _windows_kill_pids(pids: List[int]) -> None: + if platform.system() != "Windows": + return + for pid in pids or []: + try: + subprocess.run( + ["taskkill", + "/PID", + str(int(pid)), + "/F"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + **_windows_hidden_subprocess_kwargs(), + ) + except Exception: + continue + + +class MPVIPCError(Exception): + """Raised when MPV IPC communication fails.""" + + pass + + +class MPV: + """High-level MPV controller for this app. + + Responsibilities: + - Own the IPC pipe/socket path + - Start MPV with the bundled Lua script + - Query playlist and currently playing item via IPC + + This class intentionally stays "dumb": it does not implement app logic. + App behavior is driven by cmdlet (e.g. `.pipe`) and the bundled Lua script. + """ + + def __init__( + self, + ipc_path: Optional[str] = None, + lua_script_path: Optional[str | Path] = None, + timeout: float = 5.0, + check_mpv: bool = True, + ) -> None: + + if bool(check_mpv): + ok, reason = _check_mpv_availability() + if not ok: + raise MPVIPCError(reason or "MPV unavailable") + + self.timeout = timeout + self.ipc_path = ipc_path or get_ipc_pipe_path() + + if lua_script_path is None: + lua_script_path = MPV_LUA_SCRIPT_PATH + lua_path = Path(str(lua_script_path)).resolve() + self.lua_script_path = str(lua_path) + + def client(self, silent: bool = False) -> "MPVIPCClient": + return MPVIPCClient( + socket_path=self.ipc_path, + timeout=self.timeout, + silent=bool(silent) + ) + + def is_running(self) -> bool: + client = self.client(silent=True) + try: + ok = client.connect() + if ok: + return True + return self.has_process_owner() + finally: + client.disconnect() + + def has_process_owner(self) -> bool: + if platform.system() != "Windows": + return False + try: + return bool(_windows_list_mpv_pids(str(self.ipc_path))) + except Exception: + return False + + def send(self, + command: Dict[str, + Any] | List[Any], + silent: bool = False, + wait: bool = True) -> Optional[Dict[str, + Any]]: + client = self.client(silent=bool(silent)) + try: + if not client.connect(): + return None + return client.send_command(command, wait=wait) + except Exception as exc: + if not silent: + debug(f"MPV IPC error: {exc}") + return None + finally: + client.disconnect() + + def get_property(self, name: str, default: Any = None) -> Any: + resp = self.send({ + "command": ["get_property", + name] + }) + if resp and resp.get("error") == "success": + return resp.get("data", default) + return default + + def set_property(self, name: str, value: Any) -> bool: + resp = self.send({ + "command": ["set_property", + name, + value] + }) + return bool(resp and resp.get("error") == "success") + + def download( + self, + *, + url: str, + fmt: str, + store: Optional[str] = None, + path: Optional[str] = None, + ) -> Dict[str, + Any]: + """Download a URL using the same pipeline semantics as the MPV UI. + + This is intended as a stable Python entrypoint for "button actions". + It does not require mpv.exe availability (set check_mpv=False if needed). + """ + url = str(url or "").strip() + fmt = str(fmt or "").strip() + store = str(store or "").strip() if store is not None else None + path = str(path or "").strip() if path is not None else None + + if not url: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing url" + } + if not fmt: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing fmt" + } + if bool(store) == bool(path): + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Provide exactly one of store or path", + } + + # Ensure any in-process cmdlets that talk to MPV pick up this IPC path. + try: + os.environ["MEDEIA_MPV_IPC"] = str(self.ipc_path) + except Exception: + pass + + def _q(s: str) -> str: + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + + pipeline = f"download-file -url {_q(url)} -query {_q(f'format:{fmt}')}" + if store: + pipeline += f" | add-file -store {_q(store)}" + else: + pipeline += f" | add-file -path {_q(path or '')}" + + try: + from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 + + runner = PipelineRunner() + result = runner.run_pipeline(pipeline) + return { + "success": bool(getattr(result, + "success", + False)), + "stdout": getattr(result, + "stdout", + "") or "", + "stderr": getattr(result, + "stderr", + "") or "", + "error": getattr(result, + "error", + None), + "pipeline": pipeline, + } + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"{type(exc).__name__}: {exc}", + "pipeline": pipeline, + } + + def get_playlist(self, silent: bool = False) -> Optional[List[Dict[str, Any]]]: + resp = self.send( + { + "command": ["get_property", + "playlist"], + "request_id": 100 + }, + silent=silent + ) + if resp is None: + return None + if resp.get("error") == "success": + data = resp.get("data", []) + return data if isinstance(data, list) else [] + return [] + + def get_now_playing(self) -> Optional[Dict[str, Any]]: + if not self.is_running(): + return None + + playlist = self.get_playlist(silent=True) or [] + pos = self.get_property("playlist-pos", None) + path = self.get_property("path", None) + title = self.get_property("media-title", None) + + effective_path = _unwrap_memory_target(path) if isinstance(path, str) else path + + current_item: Optional[Dict[str, Any]] = None + if isinstance(pos, int) and 0 <= pos < len(playlist): + item = playlist[pos] + current_item = item if isinstance(item, dict) else None + else: + for item in playlist: + if isinstance(item, dict) and item.get("current") is True: + current_item = item + break + + return { + "path": effective_path, + "title": title, + "playlist_pos": pos, + "playlist_item": current_item, + } + + def ensure_lua_loaded(self) -> None: + try: + script_path = self.lua_script_path + if not script_path or not os.path.exists(script_path): + return + # Safe to call repeatedly; mpv will reload the script. + self.send( + { + "command": ["load-script", + script_path], + "request_id": 12 + }, + silent=True + ) + except Exception: + return + + def ensure_lyric_loader_running(self) -> None: + """Start (or keep) the Python lyric overlay helper. + + Uses the fixed IPC pipe name so it can follow playback. + """ + global _LYRIC_PROCESS, _LYRIC_LOG_FH + + # Cross-session guard (Windows): avoid spawning multiple helpers across separate CLI runs. + # Also clean up stale helpers when mpv isn't running anymore. + if platform.system() == "Windows": + try: + existing = _windows_list_lyric_helper_pids(str(self.ipc_path)) + if existing: + if not self.is_running(): + _windows_kill_pids(existing) + return + # If multiple exist, kill them and start fresh (prevents double overlays). + if len(existing) == 1: + return + _windows_kill_pids(existing) + except Exception: + pass + + try: + if _LYRIC_PROCESS is not None and _LYRIC_PROCESS.poll() is None: + return + except Exception: + pass + + try: + if _LYRIC_PROCESS is not None: + try: + _LYRIC_PROCESS.terminate() + except Exception: + pass + finally: + _LYRIC_PROCESS = None + try: + if _LYRIC_LOG_FH is not None: + _LYRIC_LOG_FH.close() + except Exception: + pass + _LYRIC_LOG_FH = None + + try: + try: + tmp_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") + except Exception: + tmp_dir = Path(".") + + # Ensure the module can be imported even when the app is launched from a different cwd. + try: + repo_root = _repo_root() + except Exception: + repo_root = Path.cwd() + + # Prefer a stable in-repo log so users can inspect it easily. + log_path = None + try: + log_dir = (repo_root / "Log") + log_dir.mkdir(parents=True, exist_ok=True) + log_path = str((log_dir / "medeia-mpv-lyric.log").resolve()) + except Exception: + log_path = None + if not log_path: + log_path = str((tmp_dir / "medeia-mpv-lyric.log").resolve()) + + py = sys.executable + if platform.system() == "Windows": + py = _windows_pythonw_exe(py) or py + + cmd: List[str] = [ + py or "python", + "-m", + "plugins.mpv.lyric", + "--ipc", + str(self.ipc_path), + "--log", + log_path, + ] + + # Redirect helper stdout/stderr to the log file so we can see crashes/import errors. + try: + _LYRIC_LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace") + except Exception: + _LYRIC_LOG_FH = None + + kwargs: Dict[str, + Any] = { + "stdin": subprocess.DEVNULL, + "stdout": _LYRIC_LOG_FH or subprocess.DEVNULL, + "stderr": _LYRIC_LOG_FH or subprocess.DEVNULL, + } + + # Ensure immediate flushing to the log file. + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + try: + existing_pp = env.get("PYTHONPATH") + env["PYTHONPATH"] = ( + str(repo_root) if not existing_pp else + (str(repo_root) + os.pathsep + str(existing_pp)) + ) + except Exception: + pass + kwargs["env"] = env + + # Make the current directory the repo root so `-m plugins.mpv.lyric` resolves reliably. + kwargs["cwd"] = str(repo_root) + if platform.system() == "Windows": + # Ensure we don't flash a console window when spawning the helper. + flags = 0 + try: + flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008)) + except Exception: + flags |= 0x00000008 + try: + flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) + except Exception: + flags |= 0x08000000 + kwargs["creationflags"] = flags + kwargs.update( + { + k: v + for k, v in _windows_hidden_subprocess_kwargs().items() + if k != "creationflags" + } + ) + + _LYRIC_PROCESS = subprocess.Popen(cmd, **kwargs) + debug(f"Lyric loader started (log={log_path})") + except Exception as exc: + debug(f"Lyric loader failed to start: {exc}") + + def wait_for_ipc(self, retries: int = 20, delay_seconds: float = 0.2) -> bool: + for _ in range(max(1, retries)): + client = self.client(silent=True) + try: + if client.connect(): + return True + finally: + client.disconnect() + _time.sleep(delay_seconds) + return False + + def kill_existing_windows(self) -> None: + if platform.system() != "Windows": + return + try: + subprocess.run( + ["taskkill", + "/IM", + "mpv.exe", + "/F"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + **_windows_hidden_subprocess_kwargs(), + ) + except Exception: + return + + def start( + self, + *, + extra_args: Optional[List[str]] = None, + ytdl_raw_options: Optional[str] = None, + http_header_fields: Optional[str] = None, + detached: bool = True, + ) -> None: + # uosc reads its config from "~~/script-opts/uosc.conf". + # With --no-config, mpv makes ~~ expand to an empty string, so uosc can't load. + # Instead, point mpv at a repo-controlled config directory. + try: + repo_root = _repo_root() + except Exception: + repo_root = Path.cwd() + + portable_config_dir = _package_root() / "portable_config" + try: + (portable_config_dir / "script-opts").mkdir(parents=True, exist_ok=True) + except Exception: + pass + + # Ensure uosc.conf is available at the location uosc expects. + try: + # Source uosc.conf is located within the bundled scripts. + src_uosc_conf = portable_config_dir / "scripts" / "uosc" / "uosc.conf" + dst_uosc_conf = portable_config_dir / "script-opts" / "uosc.conf" + if src_uosc_conf.exists(): + # Only seed a default config if the user doesn't already have one. + if not dst_uosc_conf.exists(): + dst_uosc_conf.write_bytes(src_uosc_conf.read_bytes()) + except Exception: + pass + + cmd: List[str] = [ + "mpv", + f"--config-dir={str(portable_config_dir)}", + "--load-scripts=yes", + "--osc=no", + "--ytdl=yes", + f"--input-ipc-server={self.ipc_path}", + "--idle=yes", + "--force-window=yes", + ] + + # uosc and other scripts are expected to be auto-loaded from portable_config/scripts. + # If --load-scripts=yes is set (standard), mpv will already pick up the loader shim + # at scripts/uosc.lua. We only add a manual --script fallback if that file is missing. + try: + uosc_entry = portable_config_dir / "scripts" / "uosc.lua" + if not uosc_entry.exists() and self.lua_script_path: + lua_dir = Path(self.lua_script_path).resolve().parent + # Check different possible source locations for uosc core. + uosc_paths = [ + portable_config_dir / "scripts" / "uosc" / "scripts" / "uosc" / "main.lua", + lua_dir / "uosc" / "scripts" / "uosc" / "main.lua" + ] + for p in uosc_paths: + if p.exists(): + cmd.append(f"--script={str(p)}") + break + except Exception: + pass + + # Always load the bundled Lua script at startup. + if self.lua_script_path and os.path.exists(self.lua_script_path): + cmd.append(f"--script={self.lua_script_path}") + + if ytdl_raw_options: + cmd.append(f"--ytdl-raw-options={ytdl_raw_options}") + if http_header_fields: + cmd.append(f"--http-header-fields={http_header_fields}") + if extra_args: + cmd.extend([str(a) for a in extra_args if a]) + + kwargs: Dict[str, + Any] = {} + if platform.system() == "Windows": + # Ensure we don't flash a console window when spawning mpv. + flags = 0 + try: + flags |= int( + getattr(subprocess, + "DETACHED_PROCESS", + 0x00000008) + ) if detached else 0 + except Exception: + flags |= 0x00000008 if detached else 0 + try: + flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) + except Exception: + flags |= 0x08000000 + kwargs["creationflags"] = flags + # startupinfo is harmless for GUI apps; helps hide flashes for console-subsystem builds. + kwargs.update( + { + k: v + for k, v in _windows_hidden_subprocess_kwargs().items() + if k != "creationflags" + } + ) + + debug("Starting MPV") + subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + **kwargs, + ) + + +def get_ipc_pipe_path() -> str: + """Get the fixed IPC pipe/socket path for persistent MPV connection. + + Uses a fixed name so all playback sessions connect to the same MPV + window/process instead of creating new instances. + + Returns: + Path to IPC pipe (Windows) or socket (Linux/macOS) + """ + override = os.environ.get("MEDEIA_MPV_IPC") or os.environ.get("MPV_IPC_SERVER") + if override: + return str(override) + + system = platform.system() + + if system == "Windows": + return f"\\\\.\\pipe\\{FIXED_IPC_PIPE_NAME}" + elif system == "Darwin": # macOS + return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" + else: # Linux and others + return f"/tmp/{FIXED_IPC_PIPE_NAME}.sock" + + +def _unwrap_memory_target(text: Optional[str]) -> Optional[str]: + """Return the real target from a memory:// M3U payload if present.""" + if not isinstance(text, str) or not text.startswith("memory://"): + return text + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("memory://"): + continue + return line + return text + + +class MPVIPCClient: + """Client for communicating with mpv via IPC socket/pipe. + + This is the unified interface for all Python code to communicate with mpv. + It handles platform-specific differences (Windows named pipes vs Unix sockets). + """ + + def __init__( + self, + socket_path: Optional[str] = None, + timeout: float = 5.0, + silent: bool = False + ): + """Initialize MPV IPC client. + + Args: + socket_path: Path to IPC socket/pipe. If None, uses the fixed persistent path. + timeout: Socket timeout in seconds. + """ + self.timeout = timeout + self.socket_path = socket_path or get_ipc_pipe_path() + self.sock: socket.socket | BinaryIO | None = None + self.is_windows = platform.system() == "Windows" + self.silent = bool(silent) + self._recv_buffer: bytes = b"" + + def _write_payload(self, payload: str) -> None: + if not self.sock: + if not self.connect(): + raise MPVIPCError("Not connected") + + try: + if self.is_windows: + # BinaryIO pipe from open('\\\\.\\pipe\\...') + pipe = cast(BinaryIO, self.sock) + try: + pipe.write(payload.encode("utf-8")) + pipe.flush() + except OSError as e: + # Windows Errno 22 (EINVAL) often means the pipe handle is now invalid/closed + if getattr(e, "errno", 0) == 22: + raise BrokenPipeError(str(e)) + raise + else: + sock_obj = cast(socket.socket, self.sock) + sock_obj.sendall(payload.encode("utf-8")) + except (OSError, IOError, BrokenPipeError, ConnectionResetError) as exc: + # Pipe became invalid (disconnected, corrupted, etc.). + # Disconnect and attempt one reconnection. + if not self.silent: + debug(f"Pipe write failed: {exc}; attempting reconnect") + self.disconnect() + if self.connect(): + # Retry once after reconnect + try: + if self.is_windows: + pipe = cast(BinaryIO, self.sock) + try: + pipe.write(payload.encode("utf-8")) + pipe.flush() + except OSError as e: + if getattr(e, "errno", 0) == 22: + raise BrokenPipeError(str(e)) + raise + else: + sock_obj = cast(socket.socket, self.sock) + sock_obj.sendall(payload.encode("utf-8")) + except (OSError, IOError, BrokenPipeError, ConnectionResetError) as retry_exc: + self.disconnect() + raise MPVIPCError(f"Pipe write failed after reconnect: {retry_exc}") from retry_exc + else: + raise MPVIPCError("Failed to reconnect after pipe write error") from exc + + def _readline(self, *, timeout: Optional[float] = None) -> Optional[bytes]: + if not self.sock: + if not self.connect(): + return None + + effective_timeout = self.timeout if timeout is None else float(timeout) + deadline = _time.time() + max(0.0, effective_timeout) + + if self.is_windows: + pipe = cast(BinaryIO, self.sock) + while True: + nl = self._recv_buffer.find(b"\n") + if nl != -1: + line = self._recv_buffer[:nl + 1] + self._recv_buffer = self._recv_buffer[nl + 1:] + return line + + remaining = deadline - _time.time() + if remaining <= 0: + return None + + try: + available = _windows_pipe_bytes_available(pipe) + except Exception as exc: + if not self.silent: + debug(f"Pipe availability probe failed: {exc}") + self.disconnect() + return None + + if available is None: + self.disconnect() + return None + + if available <= 0: + _time.sleep(min(0.01, max(0.001, remaining))) + continue + + try: + chunk = pipe.read(min(available, 4096)) + except (OSError, IOError, BrokenPipeError, ConnectionResetError) as exc: + if not self.silent: + debug(f"Pipe readline failed: {exc}") + self.disconnect() + return None + + if not chunk: + return b"" + + self._recv_buffer += chunk + + # Unix: buffer until newline. + sock_obj = cast(socket.socket, self.sock) + while True: + nl = self._recv_buffer.find(b"\n") + if nl != -1: + line = self._recv_buffer[:nl + 1] + self._recv_buffer = self._recv_buffer[nl + 1:] + return line + + remaining = deadline - _time.time() + if remaining <= 0: + return None + + try: + # Temporarily narrow timeout for this read. + old_timeout = sock_obj.gettimeout() + try: + sock_obj.settimeout(remaining) + chunk = sock_obj.recv(4096) + finally: + sock_obj.settimeout(old_timeout) + except socket.timeout: + return None + except Exception: + return None + + if not chunk: + # EOF + return b"" + self._recv_buffer += chunk + + def read_message(self, + *, + timeout: Optional[float] = None) -> Optional[Dict[str, + Any]]: + """Read the next JSON message/event from MPV. + + Returns: + - dict: parsed JSON message/event + - {"event": "__eof__"} if the stream ended + - None on timeout / no data + """ + raw = self._readline(timeout=timeout) + if raw is None: + return None + if raw == b"": + return { + "event": "__eof__" + } + try: + return json.loads(raw.decode("utf-8", errors="replace").strip()) + except Exception: + return None + + def send_command_no_wait(self, + command_data: Dict[str, + Any] | List[Any]) -> Optional[int]: + """Send a command to mpv without waiting for its response. + + This is important for long-running event loops (helpers) so we don't + consume/lose async events (like property-change) while waiting. + """ + try: + request: Dict[str, Any] + if isinstance(command_data, list): + request = { + "command": command_data + } + else: + request = dict(command_data) + + if "request_id" not in request: + request["request_id"] = int(_time.time() * 1000) % 100000 + + payload = json.dumps(request) + "\n" + self._write_payload(payload) + return int(request["request_id"]) + except Exception as exc: + if not self.silent: + debug(f"Error sending no-wait command to MPV: {exc}") + try: + self.disconnect() + except Exception: + pass + return None + + def connect(self) -> bool: + """Connect to mpv IPC socket. + + Returns: + True if connection successful, False otherwise. + """ + 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) + self._recv_buffer = b"" + return True + except (OSError, IOError) as exc: + if not self.silent: + debug(f"Failed to connect to MPV named pipe: {exc}") + return False + else: + # Unix domain socket (Linux, macOS) + if not os.path.exists(self.socket_path): + if not self.silent: + debug(f"IPC socket not found: {self.socket_path}") + return False + + af_unix = getattr(socket, "AF_UNIX", None) + if af_unix is None: + if not self.silent: + debug("IPC AF_UNIX is not available on this platform") + return False + + self.sock = socket.socket(af_unix, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect(self.socket_path) + self._recv_buffer = b"" + return True + except Exception as exc: + if not self.silent: + debug(f"Failed to connect to MPV IPC: {exc}") + self.sock = None + return False + + def send_command(self, + command_data: Dict[str, + Any] | List[Any], + wait: bool = True) -> Optional[Dict[str, + Any]]: + """Send a command to mpv and get response. + + Args: + command_data: Command dict (e.g. {"command": [...]}) or list (e.g. ["loadfile", ...]) + wait: If True, wait for the command response. + + Returns: + Response dict with 'error' key (value 'success' on success), or None on error. + """ + if not self.sock: + if not self.connect(): + return None + + try: + # Format command as JSON (mpv IPC protocol) + request: Dict[str, Any] + if isinstance(command_data, list): + request = { + "command": command_data + } + else: + request = command_data + + # Add request_id if not present to match response + if "request_id" not in request: + request["request_id"] = int(_time.time() * 1000) % 100000 + + rid = request["request_id"] + payload = json.dumps(request) + "\n" + + # Send command + self._write_payload(payload) + + if not wait: + return {"error": "success", "request_id": rid, "async": True} + + # Receive response + # We need to read lines until we find the one with matching request_id + # or until timeout/error. MPV might send events in between. + start_time = _time.time() + while _time.time() - start_time < self.timeout: + response_data = self._readline(timeout=self.timeout) + if response_data is None: + return None + + if not response_data: + break + + try: + lines = response_data.decode( + "utf-8", + errors="replace" + ).strip().split("\n") + for line in lines: + if not line: + continue + resp = json.loads(line) + + # Check if this is the response to our request + if resp.get("request_id") == request.get("request_id"): + return resp + + # Handle async log messages/events for visibility + event_type = resp.get("event") + if event_type == "log-message": + level = resp.get("level", "info") + prefix = resp.get("prefix", "") + text = resp.get("text", "").strip() + debug(f"[MPV {level}] {prefix} {text}".strip()) + elif event_type: + debug(f"[MPV event] {event_type}: {resp}") + elif "error" in resp and "request_id" not in resp: + debug(f"[MPV error] {resp}") + except json.JSONDecodeError: + pass + + return None + except Exception as exc: + debug(f"Error sending command to MPV: {exc}") + self.disconnect() + return None + + def disconnect(self) -> None: + """Disconnect from mpv IPC socket.""" + if self.sock: + try: + self.sock.close() + except Exception: + pass + self.sock = None + self._recv_buffer = b"" + + def __del__(self) -> None: + """Cleanup on object destruction.""" + self.disconnect() + + def __enter__(self): + """Context manager entry.""" + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.disconnect() diff --git a/plugins/mpv/pipeline_helper.py b/plugins/mpv/pipeline_helper.py new file mode 100644 index 0000000..1ca6362 --- /dev/null +++ b/plugins/mpv/pipeline_helper.py @@ -0,0 +1,2173 @@ +"""Persistent MPV pipeline helper. + +This process connects to MPV's IPC server, observes a user-data property for +pipeline execution requests, runs the pipeline in-process, and posts results +back to MPV via user-data properties. + +Why: +- Avoid spawning a new Python process for every MPV action. +- Enable MPV Lua scripts to trigger any cmdlet pipeline cheaply. + +Protocol (user-data properties): +- Request: user-data/medeia-pipeline-request (JSON string) + {"id": "...", "pipeline": "...", "seeds": [...]} (seeds optional) +- Response: user-data/medeia-pipeline-response (JSON string) + {"id": "...", "success": bool, "stdout": "...", "stderr": "...", "error": "..."} +- Ready: user-data/medeia-pipeline-ready ("1") + +This helper is intentionally minimal: one request at a time, last-write-wins. +""" + +from __future__ import annotations + +MEDEIA_MPV_HELPER_VERSION = "2026-03-23.1" + +import argparse +import json +import os +import sys +import tempfile +import time +import threading +import logging +import re +import hashlib +import subprocess +import platform +from pathlib import Path +from typing import Any, Callable, Dict, Optional + + +def _repo_root() -> Path: + package_dir = Path(__file__).resolve().parent + if package_dir.name.lower() == "mpv" and package_dir.parent.name.lower() == "plugins": + return package_dir.parent.parent + return package_dir.parent + + +def _runtime_config_root() -> Path: + """Best-effort config root for runtime execution. + + MPV can spawn this helper from an installed location while setting `cwd` to + the repo root (see MPV.mpv_ipc). Prefer `cwd` when it contains `config.conf`. + """ + try: + cwd = Path.cwd().resolve() + if (cwd / "config.conf").exists(): + return cwd + except Exception: + pass + return _repo_root() + + +# Make repo-local packages importable even when mpv starts us from another cwd. +_ROOT = str(_repo_root()) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from plugins.mpv.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs, _windows_list_mpv_pids # noqa: E402 +from SYS.config import load_config, reload_config # noqa: E402 +from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402 +from SYS.repl_queue import enqueue_repl_command # noqa: E402 +from SYS.utils import format_bytes # noqa: E402 +from ProviderCore.registry import get_plugin, get_plugin_class # noqa: E402 +from tool.ytdlp import get_display_format_id, get_selection_format_id # noqa: E402 + +REQUEST_PROP = "user-data/medeia-pipeline-request" +RESPONSE_PROP = "user-data/medeia-pipeline-response" +READY_PROP = "user-data/medeia-pipeline-ready" +VERSION_PROP = "user-data/medeia-pipeline-helper-version" + +OBS_ID_REQUEST = 1001 + +_HELPER_MPV_LOG_EMITTER: Optional[Callable[[str], None]] = None +_HELPER_LOG_BACKLOG: list[str] = [] +_HELPER_LOG_BACKLOG_LIMIT = 200 +_ASYNC_PIPELINE_JOBS: Dict[str, Dict[str, Any]] = {} +_ASYNC_PIPELINE_JOBS_LOCK = threading.Lock() +_ASYNC_PIPELINE_JOB_TTL_SECONDS = 900.0 +_STORE_CHOICES_CACHE: list[str] = [] +_STORE_CHOICES_CACHE_LOCK = threading.Lock() + + +def _normalize_store_choices(values: Any) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + if not isinstance(values, (list, tuple, set)): + return out + for item in values: + text = str(item or "").strip() + if not text: + continue + key = text.casefold() + if key in seen: + continue + seen.add(key) + out.append(text) + return sorted(out, key=str.casefold) + + +def _get_cached_store_choices() -> list[str]: + with _STORE_CHOICES_CACHE_LOCK: + return list(_STORE_CHOICES_CACHE) + + +def _set_cached_store_choices(choices: Any) -> list[str]: + normalized = _normalize_store_choices(choices) + with _STORE_CHOICES_CACHE_LOCK: + _STORE_CHOICES_CACHE[:] = normalized + return list(_STORE_CHOICES_CACHE) + + +def _store_choices_payload(choices: Any) -> Optional[str]: + cached = _normalize_store_choices(choices) + if not cached: + return None + return json.dumps( + { + "success": True, + "choices": cached, + }, + ensure_ascii=False, + ) + + +def _load_store_choices_from_config(*, force_reload: bool = False) -> list[str]: + from Store.registry import list_configured_backend_names # noqa: WPS433 + + cfg = reload_config() if force_reload else load_config() + return _normalize_store_choices(list_configured_backend_names(cfg or {})) + + +def _prune_async_pipeline_jobs(now: Optional[float] = None) -> None: + cutoff = float(now or time.time()) - _ASYNC_PIPELINE_JOB_TTL_SECONDS + with _ASYNC_PIPELINE_JOBS_LOCK: + expired = [ + job_id + for job_id, job in _ASYNC_PIPELINE_JOBS.items() + if float(job.get("updated_at") or 0.0) < cutoff + ] + for job_id in expired: + _ASYNC_PIPELINE_JOBS.pop(job_id, None) + + +def _update_async_pipeline_job(job_id: str, **fields: Any) -> Optional[Dict[str, Any]]: + if not job_id: + return None + + now = time.time() + _prune_async_pipeline_jobs(now) + + with _ASYNC_PIPELINE_JOBS_LOCK: + job = dict(_ASYNC_PIPELINE_JOBS.get(job_id) or {}) + if not job: + job = { + "job_id": job_id, + "status": "queued", + "success": False, + "created_at": now, + } + job.update(fields) + job["updated_at"] = now + _ASYNC_PIPELINE_JOBS[job_id] = job + return dict(job) + + +def _get_async_pipeline_job(job_id: str) -> Optional[Dict[str, Any]]: + if not job_id: + return None + _prune_async_pipeline_jobs() + with _ASYNC_PIPELINE_JOBS_LOCK: + job = _ASYNC_PIPELINE_JOBS.get(job_id) + return dict(job) if job else None + + +def _append_prefixed_log_lines(prefix: str, text: Any, *, max_lines: int = 40) -> None: + payload = str(text or "").replace("\r\n", "\n").replace("\r", "\n") + if not payload.strip(): + return + + emitted = 0 + for line in payload.split("\n"): + line = line.rstrip() + if not line: + continue + _append_helper_log(f"{prefix}{line}") + emitted += 1 + if emitted >= max_lines: + _append_helper_log(f"{prefix}... truncated after {max_lines} lines") + break + + +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: + 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: + pass + stop_event.wait(0.75) + + try: + hb_client.disconnect() + except Exception: + pass + + thread = threading.Thread( + target=_heartbeat_loop, + name="mpv-helper-heartbeat", + daemon=True, + ) + thread.start() + return thread + + +def _windows_list_pipeline_helper_pids(ipc_path: str) -> list[int]: + if platform.system() != "Windows": + return [] + try: + ipc_path = str(ipc_path or "") + except Exception: + ipc_path = "" + if not ipc_path: + return [] + + ps_script = ( + "$ipc = " + json.dumps(ipc_path) + "; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.CommandLine -and (($_.CommandLine -match 'pipeline_helper\\.py') -or ($_.CommandLine -match ' -m\\s+MPV\\.pipeline_helper(\\s|$)')) -and $_.CommandLine -match ('--ipc\\s+' + [regex]::Escape($ipc)) } | " + "Select-Object -ExpandProperty ProcessId | ConvertTo-Json -Compress" + ) + + try: + out = subprocess.check_output( + ["powershell", "-NoProfile", "-Command", ps_script], + stdin=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=3, + text=True, + **_windows_hidden_subprocess_kwargs(), + ) + except Exception: + return [] + + txt = (out or "").strip() + if not txt or txt == "null": + return [] + + try: + obj = json.loads(txt) + except Exception: + return [] + + raw_pids = obj if isinstance(obj, list) else [obj] + out_pids: list[int] = [] + for value in raw_pids: + try: + pid = int(value) + except Exception: + continue + if pid > 0 and pid not in out_pids: + out_pids.append(pid) + return out_pids + + +def _run_pipeline( + pipeline_text: str, + *, + seeds: Any = None, + json_output: bool = False, +) -> Dict[str, Any]: + # Import after sys.path fix. + from TUI.pipeline_runner import PipelineRunner # noqa: WPS433 + + def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, dict): + out: Dict[str, Any] = {} + for key, item in value.items(): + out[str(key)] = _json_safe(item) + return out + if isinstance(value, (list, tuple, set)): + return [_json_safe(item) for item in value] + if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")): + try: + return _json_safe(value.to_dict()) + except Exception: + pass + return str(value) + + def _table_to_payload(table: Any) -> Optional[Dict[str, Any]]: + if table is None: + return None + try: + title = getattr(table, "title", "") + except Exception: + title = "" + + rows_payload = [] + try: + rows = getattr(table, "rows", None) + except Exception: + rows = None + if isinstance(rows, list): + for r in rows: + cols_payload = [] + try: + cols = getattr(r, "columns", None) + except Exception: + cols = None + if isinstance(cols, list): + for c in cols: + try: + cols_payload.append( + { + "name": getattr(c, + "name", + ""), + "value": getattr(c, + "value", + ""), + } + ) + except Exception: + continue + + sel_args = None + try: + sel = getattr(r, "selection_args", None) + if isinstance(sel, list): + sel_args = [str(x) for x in sel] + except Exception: + sel_args = None + + rows_payload.append( + { + "columns": cols_payload, + "selection_args": sel_args + } + ) + + # Only return JSON-serializable data (Lua only needs title + rows). + return { + "title": str(title or ""), + "rows": rows_payload + } + + start_time = time.time() + runner = PipelineRunner() + result = runner.run_pipeline(pipeline_text, seeds=seeds) + duration = time.time() - start_time + try: + _append_helper_log( + f"[pipeline] run_pipeline completed in {duration:.2f}s pipeline={pipeline_text[:64]}" + ) + except Exception: + pass + + table_payload = None + try: + table_payload = _table_to_payload(getattr(result, "result_table", None)) + except Exception: + table_payload = None + + data_payload = None + if json_output: + try: + data_payload = _json_safe(getattr(result, "emitted", None) or []) + except Exception: + data_payload = [] + + return { + "success": bool(result.success), + "stdout": result.stdout or "", + "stderr": result.stderr or "", + "error": result.error, + "table": table_payload, + "data": data_payload, + } + + +def _run_pipeline_background( + pipeline_text: str, + *, + seeds: Any, + req_id: str, + job_id: Optional[str] = None, + json_output: bool = False, +) -> None: + def _target() -> None: + try: + if job_id: + _update_async_pipeline_job( + job_id, + status="running", + success=False, + pipeline=pipeline_text, + req_id=req_id, + started_at=time.time(), + finished_at=None, + error=None, + stdout="", + stderr="", + table=None, + data=None, + ) + + result = _run_pipeline( + pipeline_text, + seeds=seeds, + json_output=json_output, + ) + status = "success" if result.get("success") else "failed" + if job_id: + _update_async_pipeline_job( + job_id, + status=status, + success=bool(result.get("success")), + finished_at=time.time(), + error=result.get("error"), + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + table=result.get("table"), + data=result.get("data"), + ) + _append_helper_log( + f"[pipeline async {req_id}] {status}" + + (f" job={job_id}" if job_id else "") + + f" error={result.get('error')}" + ) + _append_prefixed_log_lines( + f"[pipeline async stdout {req_id}] ", + result.get("stdout", ""), + ) + _append_prefixed_log_lines( + f"[pipeline async stderr {req_id}] ", + result.get("stderr", ""), + ) + except Exception as exc: # pragma: no cover - best-effort logging + if job_id: + _update_async_pipeline_job( + job_id, + status="failed", + success=False, + finished_at=time.time(), + error=f"{type(exc).__name__}: {exc}", + stdout="", + stderr="", + table=None, + data=None, + ) + _append_helper_log( + f"[pipeline async {req_id}] exception" + + (f" job={job_id}" if job_id else "") + + f": {type(exc).__name__}: {exc}" + ) + + thread = threading.Thread( + target=_target, + name=f"pipeline-async-{req_id}", + daemon=True, + ) + thread.start() + + +def _is_load_url_pipeline(pipeline_text: str) -> bool: + return str(pipeline_text or "").lstrip().lower().startswith(".mpv -url") + + +def _run_op(op: str, data: Any) -> Dict[str, Any]: + """Run a helper-only operation. + + These are NOT cmdlets and are not available via CLI pipelines. They exist + solely so MPV Lua can query lightweight metadata (e.g., autocomplete lists) + without inventing user-facing commands. + """ + op_name = str(op or "").strip().lower() + + if op_name in {"run-background", + "run_background", + "pipeline-background", + "pipeline_background"}: + pipeline_text = "" + seeds = None + json_output = False + requested_job_id = "" + if isinstance(data, dict): + pipeline_text = str(data.get("pipeline") or "").strip() + seeds = data.get("seeds") + json_output = bool(data.get("json") or data.get("output_json")) + requested_job_id = str(data.get("job_id") or "").strip() + + if not pipeline_text: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing pipeline", + "table": None, + } + + token = requested_job_id or f"job-{int(time.time() * 1000)}-{threading.get_ident()}" + job_id = hashlib.sha1( + f"{token}|{pipeline_text}|{time.time()}".encode("utf-8", "ignore") + ).hexdigest()[:16] + + _update_async_pipeline_job( + job_id, + status="queued", + success=False, + pipeline=pipeline_text, + req_id=job_id, + finished_at=None, + error=None, + stdout="", + stderr="", + table=None, + data=None, + ) + _run_pipeline_background( + pipeline_text, + seeds=seeds, + req_id=job_id, + job_id=job_id, + json_output=json_output, + ) + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "job_id": job_id, + "status": "queued", + } + + if op_name in {"job-status", + "job_status", + "pipeline-job-status", + "pipeline_job_status"}: + job_id = "" + if isinstance(data, dict): + job_id = str(data.get("job_id") or "").strip() + if not job_id: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing job_id", + "table": None, + } + + job = _get_async_pipeline_job(job_id) + if not job: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"Unknown job_id: {job_id}", + "table": None, + } + + payload = dict(job) + return { + "success": True, + "stdout": str(payload.get("stdout") or ""), + "stderr": str(payload.get("stderr") or ""), + "error": payload.get("error"), + "table": payload.get("table"), + "data": payload.get("data"), + "job": payload, + } + + if op_name in {"queue-repl-command", + "queue_repl_command", + "repl-command", + "repl_command"}: + command_text = "" + source = "mpv" + metadata = None + if isinstance(data, dict): + command_text = str(data.get("command") or data.get("pipeline") or "").strip() + source = str(data.get("source") or "mpv").strip() or "mpv" + metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else None + + if not command_text: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing command", + "table": None, + } + + queue_path = enqueue_repl_command( + _repo_root(), + command_text, + source=source, + metadata=metadata, + ) + _append_helper_log( + f"[repl-queue] queued source={source} path={queue_path.name} cmd={command_text}" + ) + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "path": str(queue_path), + "queued": True, + } + + if op_name in {"run-detached", + "run_detached", + "pipeline-detached", + "pipeline_detached"}: + pipeline_text = "" + seeds = None + if isinstance(data, dict): + pipeline_text = str(data.get("pipeline") or "").strip() + seeds = data.get("seeds") + if not pipeline_text: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing pipeline", + "table": None, + } + + py = sys.executable or "python" + if platform.system() == "Windows": + try: + exe = str(py or "").strip() + except Exception: + exe = "" + low = exe.lower() + if low.endswith("python.exe"): + try: + candidate = exe[:-10] + "pythonw.exe" + if os.path.exists(candidate): + py = candidate + except Exception: + pass + + cmd = [ + py, + str((_repo_root() / "CLI.py").resolve()), + "pipeline", + "--pipeline", + pipeline_text, + ] + if seeds is not None: + try: + cmd.extend(["--seeds-json", json.dumps(seeds, ensure_ascii=False)]) + except Exception: + # Best-effort; seeds are optional. + pass + + popen_kwargs: Dict[str, + Any] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "cwd": str(_repo_root()), + } + if platform.system() == "Windows": + flags = 0 + try: + flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008)) + except Exception: + flags |= 0x00000008 + try: + flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)) + except Exception: + flags |= 0x08000000 + popen_kwargs["creationflags"] = int(flags) + try: + si = subprocess.STARTUPINFO() + si.dwFlags |= int( + getattr(subprocess, + "STARTF_USESHOWWINDOW", + 0x00000001) + ) + si.wShowWindow = subprocess.SW_HIDE + popen_kwargs["startupinfo"] = si + except Exception: + pass + else: + popen_kwargs["start_new_session"] = True + + try: + proc = subprocess.Popen(cmd, **popen_kwargs) + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": + f"Failed to spawn detached pipeline: {type(exc).__name__}: {exc}", + "table": None, + } + + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "pid": int(getattr(proc, + "pid", + 0) or 0), + } + + # Provide store backend choices using the dynamic registered store registry only. + if op_name in {"store-choices", + "store_choices", + "get-store-choices", + "get_store_choices"}: + cached_choices = _get_cached_store_choices() + refresh = False + if isinstance(data, dict): + refresh = bool(data.get("refresh") or data.get("reload")) + + if cached_choices and not refresh: + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "choices": cached_choices, + } + + try: + choices = _load_store_choices_from_config(force_reload=refresh) + + if not choices and cached_choices: + choices = cached_choices + + if choices: + choices = _set_cached_store_choices(choices) + + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "choices": choices, + } + except Exception as exc: + if cached_choices: + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "choices": cached_choices, + } + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"store-choices failed: {type(exc).__name__}: {exc}", + "table": None, + "choices": [], + } + + if op_name in {"url-exists", + "url_exists", + "find-url", + "find_url"}: + try: + from Store import Store # noqa: WPS433 + + cfg = load_config() or {} + storage = Store(config=cfg, suppress_debug=True) + + raw_needles: list[str] = [] + if isinstance(data, dict): + maybe_needles = data.get("needles") + if isinstance(maybe_needles, (list, tuple, set)): + for item in maybe_needles: + text = str(item or "").strip() + if text and text not in raw_needles: + raw_needles.append(text) + elif isinstance(maybe_needles, str): + text = maybe_needles.strip() + if text: + raw_needles.append(text) + + if not raw_needles: + text = str(data.get("url") or "").strip() + if text: + raw_needles.append(text) + + if not raw_needles: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing url", + "table": None, + "data": [], + } + + matches: list[dict[str, Any]] = [] + seen_keys: set[str] = set() + + for backend_name in storage.list_backends() or []: + try: + backend = storage[backend_name] + except Exception: + continue + + search_fn = getattr(backend, "search", None) + if not callable(search_fn): + continue + + for needle in raw_needles: + query = f"url:{needle}" + try: + results = backend.search( + query, + limit=1, + minimal=True, + url_only=True, + ) or [] + except Exception: + continue + + for item in results: + if hasattr(item, "to_dict") and callable(getattr(item, "to_dict")): + try: + item = item.to_dict() + except Exception: + item = {"title": str(item)} + elif not isinstance(item, dict): + item = {"title": str(item)} + + payload = dict(item) + payload.setdefault("store", str(backend_name)) + payload.setdefault("needle", str(needle)) + + key = str(payload.get("hash") or payload.get("url") or payload.get("title") or needle).strip().lower() + if key in seen_keys: + continue + seen_keys.add(key) + matches.append(payload) + + if matches: + break + + if matches: + break + + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": None, + "data": matches, + } + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"url-exists failed: {type(exc).__name__}: {exc}", + "table": None, + "data": [], + } + + # Provide yt-dlp format list for a URL (for MPV "Change format" menu). + # Returns a ResultTable-like payload so the Lua UI can render without running cmdlets. + if op_name in {"ytdlp-formats", + "ytdlp_formats", + "ytdl-formats", + "ytdl_formats"}: + try: + url = None + if isinstance(data, dict): + url = data.get("url") + url = str(url or "").strip() + if not url: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "Missing url", + "table": None, + } + + cfg = load_config() or {} + plugin = get_plugin("ytdlp", cfg) + if plugin is None or not hasattr(plugin, "list_url_formats"): + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "yt-dlp plugin unavailable", + "table": None, + } + + try: + formats = plugin.list_url_formats( + url, + no_playlist=True, + timeout_seconds=25, + ) + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"yt-dlp plugin probe failed: {type(exc).__name__}: {exc}", + "table": None, + } + + def _format_bytes(n: Any) -> str: + """Format bytes using centralized utility.""" + return format_bytes(n) + + if formats is None: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": "yt-dlp format probe failed or timed out", + "table": None, + } + + if not formats: + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": { + "title": "Formats", + "rows": [] + }, + } + + try: + formats = plugin.filter_picker_formats(formats) + except Exception: + pass + + # Debug: dump a short summary of the format list to the helper log. + try: + count = len(formats) + _append_helper_log( + f"[ytdlp-formats] extracted formats count={count} url={url}" + ) + + limit = 60 + for i, f in enumerate(formats[:limit], start=1): + if not isinstance(f, dict): + continue + fid = str(f.get("format_id") or "") + ext = str(f.get("ext") or "") + note = f.get("format_note") or f.get("format") or "" + vcodec = str(f.get("vcodec") or "") + acodec = str(f.get("acodec") or "") + size = f.get("filesize") or f.get("filesize_approx") + res = str(f.get("resolution") or "") + if not res: + try: + w = f.get("width") + h = f.get("height") + if w and h: + res = f"{int(w)}x{int(h)}" + elif h: + res = f"{int(h)}p" + except Exception: + res = "" + _append_helper_log( + f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}" + ) + if count > limit: + _append_helper_log( + f"[ytdlp-formats] (truncated; total={count})" + ) + except Exception: + pass + + rows = [] + for fmt in formats: + if not isinstance(fmt, dict): + continue + format_id = str(fmt.get("format_id") or "").strip() + if not format_id: + continue + display_id = get_display_format_id(fmt) or format_id + + # Prefer human-ish resolution. + resolution = str(fmt.get("resolution") or "").strip() + if not resolution: + w = fmt.get("width") + h = fmt.get("height") + try: + if w and h: + resolution = f"{int(w)}x{int(h)}" + elif h: + resolution = f"{int(h)}p" + except Exception: + resolution = "" + + ext = str(fmt.get("ext") or "").strip() + size = _format_bytes(fmt.get("filesize") or fmt.get("filesize_approx")) + + selection_id = get_selection_format_id(fmt, video_audio_suffix="ba") or format_id + + # Build selection args compatible with MPV Lua picker. + # Use -format instead of -query so Lua can extract the ID easily. + selection_args = ["-format", selection_id] + + rows.append( + { + "columns": [ + { + "name": "ID", + "value": display_id + }, + { + "name": "Resolution", + "value": resolution or "" + }, + { + "name": "Ext", + "value": ext or "" + }, + { + "name": "Size", + "value": size or "" + }, + ], + "selection_args": + selection_args, + } + ) + + return { + "success": True, + "stdout": "", + "stderr": "", + "error": None, + "table": { + "title": "Formats", + "rows": rows + }, + } + except Exception as exc: + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"{type(exc).__name__}: {exc}", + "table": None, + } + + return { + "success": False, + "stdout": "", + "stderr": "", + "error": f"Unknown op: {op_name}", + "table": None, + } + + +def _append_helper_log(text: str) -> None: + """Log helper diagnostics to file, database, and the mpv console emitter.""" + payload = (text or "").rstrip() + if not payload: + return + + _HELPER_LOG_BACKLOG.append(payload) + if len(_HELPER_LOG_BACKLOG) > _HELPER_LOG_BACKLOG_LIMIT: + del _HELPER_LOG_BACKLOG[:-_HELPER_LOG_BACKLOG_LIMIT] + + try: + with open(_helper_log_path(), "a", encoding="utf-8", errors="replace") as fh: + fh.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {payload}\n") + except Exception: + pass + + try: + # Try database logging first (best practice: unified logging) + from SYS.database import log_to_db + log_to_db("INFO", "mpv", payload) + except Exception: + # Fallback to stderr if database unavailable + import sys + print(f"[mpv-helper] {payload}", file=sys.stderr) + + emitter = _HELPER_MPV_LOG_EMITTER + if emitter is not None: + try: + emitter(payload) + except Exception: + pass + + +def _helper_log_path() -> str: + try: + log_dir = _repo_root() / "Log" + log_dir.mkdir(parents=True, exist_ok=True) + return str((log_dir / "medeia-mpv-helper.log").resolve()) + except Exception: + tmp = tempfile.gettempdir() + return str((Path(tmp) / "medeia-mpv-helper.log").resolve()) + + +def _get_ipc_lock_path(ipc_path: str) -> Path: + """Return the lock file path for a given IPC path.""" + safe = re.sub(r"[^a-zA-Z0-9_.-]+", "_", str(ipc_path or "")) + if not safe: + safe = "mpv" + lock_dir = Path(tempfile.gettempdir()) / "medeia-mpv-helper" + lock_dir.mkdir(parents=True, exist_ok=True) + return lock_dir / f"medeia-mpv-helper-{safe}.lock" + + +def _get_ipc_lock_meta_path(ipc_path: str) -> Path: + lock_path = _get_ipc_lock_path(ipc_path) + return lock_path.with_suffix(lock_path.suffix + ".json") + + +def _read_lock_file_pid(ipc_path: str) -> Optional[int]: + """Return the PID recorded in the lock file by the current holder, or None. + + The lock file can be opened for reading even while another process holds the + byte-range lock (msvcrt.locking is advisory, not a file-open exclusive lock). + This lets a challenger identify the exact holder PID and kill only that process, + avoiding the race where concurrent sibling helpers all kill each other. + """ + try: + lock_path = _get_ipc_lock_meta_path(ipc_path) + with open(str(lock_path), "r", encoding="utf-8", errors="replace") as fh: + content = fh.read().strip() + if not content: + return None + data = json.loads(content) + pid = int(data.get("pid") or 0) + return pid if pid > 0 else None + except Exception: + return None + + +def _write_lock_file_metadata(ipc_path: str) -> None: + meta_path = _get_ipc_lock_meta_path(ipc_path) + meta_path.write_text( + json.dumps( + { + "pid": os.getpid(), + "version": MEDEIA_MPV_HELPER_VERSION, + "ipc": str(ipc_path), + "started_at": int(time.time()), + }, + ensure_ascii=False, + ), + encoding="utf-8", + errors="replace", + ) + + +def _release_ipc_lock(fh: Any, ipc_path: Optional[str] = None) -> None: + if fh is None: + if ipc_path: + try: + _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) + except Exception: + pass + return + try: + if os.name == "nt": + import msvcrt # type: ignore + + try: + fh.seek(0) + except Exception: + pass + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl # type: ignore + + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + except Exception: + pass + try: + fh.close() + except Exception: + pass + if ipc_path: + try: + _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) + except Exception: + pass + + +def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: + """Best-effort singleton lock per IPC path. + + Multiple helpers subscribing to mpv log-message events causes duplicated + log output. Use a tiny file lock to ensure one helper per mpv instance. + """ + try: + lock_path = _get_ipc_lock_path(ipc_path) + fh = open(lock_path, "a+", encoding="utf-8", errors="replace") + + # On Windows, locking a zero-length file can fail even when no process + # actually owns the lock anymore. Prime the file with a single byte so + # stale empty lock files do not wedge future helper startups. + try: + fh.seek(0, os.SEEK_END) + if fh.tell() < 1: + fh.write("\n") + fh.flush() + except Exception: + pass + + if os.name == "nt": + try: + import msvcrt # type: ignore + + fh.seek(0) + msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1) + except Exception: + try: + fh.close() + except Exception: + pass + return None + else: + try: + import fcntl # type: ignore + + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except Exception: + try: + fh.close() + except Exception: + pass + return None + + try: + _write_lock_file_metadata(ipc_path) + except Exception: + pass + + return fh + except Exception: + return None + + +def _parse_request(data: Any) -> Optional[Dict[str, Any]]: + if data is None: + return None + if isinstance(data, str): + text = data.strip() + if not text: + return None + try: + obj = json.loads(text) + except Exception: + return None + return obj if isinstance(obj, dict) else None + if isinstance(data, dict): + return data + return None + + +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. + + Windows named-pipe event delivery can stall even while direct get/set + property commands still work. Polling the request property provides a more + reliable fallback transport for helper ops and pipeline dispatch. + """ + + def _poll_loop() -> None: + poll_client = MPVIPCClient(socket_path=ipc_path, timeout=0.75, silent=True) + while not stop_event.is_set(): + try: + 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: + pass + 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: + pass + stop_event.wait(0.15) + + try: + poll_client.disconnect() + except Exception: + pass + + thread = threading.Thread( + target=_poll_loop, + name="mpv-helper-request-poll", + daemon=True, + ) + thread.start() + return thread + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser(prog="mpv-pipeline-helper") + parser.add_argument("--ipc", required=True, help="mpv --input-ipc-server path") + parser.add_argument("--timeout", type=float, default=15.0) + args = parser.parse_args(argv) + + # Load config once and configure logging similar to CLI.pipeline. + try: + cfg = load_config() or {} + except Exception: + cfg = {} + + try: + debug_enabled = bool(isinstance(cfg, dict) and cfg.get("debug", False)) + set_debug(debug_enabled) + + if debug_enabled: + logging.basicConfig( + level=logging.DEBUG, + format="[%(name)s] %(levelname)s: %(message)s", + stream=sys.stderr, + ) + for noisy in ("httpx", + "httpcore", + "httpcore.http11", + "httpcore.connection"): + try: + logging.getLogger(noisy).setLevel(logging.WARNING) + except Exception: + pass + except Exception: + pass + + # Ensure all in-process cmdlets that talk to MPV pick up the exact IPC server + # path used by this helper (which comes from the running MPV instance). + os.environ["MEDEIA_MPV_IPC"] = str(args.ipc) + + # Generous deadline: the kill + OS-lock-release cycle can take several seconds, + # especially when a stale helper is running as a different process image. + lock_wait_deadline = time.time() + 12.0 + lock_wait_logged = False + _lock_fh = None + _kill_attempted = False # kill at most once; re-killing on every loop causes sibling helpers to kill each other + + while _lock_fh is None: + # Try to acquire the lock first — avoids unnecessary process enumeration + # when there is no contention (normal cold-start path). + _lock_fh = _acquire_ipc_lock(str(args.ipc)) + if _lock_fh is not None: + break + + if not lock_wait_logged: + _append_helper_log( + f"[helper] waiting for helper lock release ipc={args.ipc}" + ) + lock_wait_logged = True + + if time.time() >= lock_wait_deadline: + _append_helper_log( + f"[helper] another instance still holds lock for ipc={args.ipc}; exiting after wait" + ) + return 0 + + # Kill the lock holder at most once. Repeatedly scanning for all matching + # processes on every iteration caused concurrent sibling helpers (spawned by + # the Lua 3-second timeout cycle) to kill each other before any could start. + if platform.system() == "Windows" and not _kill_attempted: + _kill_attempted = True + try: + # Prefer targeted kill via PID recorded in the lock file. + # msvcrt byte-range locking does not prevent reading the file from + # another process, so we can always identify the exact holder PID. + holder_pid = _read_lock_file_pid(str(args.ipc)) + if holder_pid and holder_pid != os.getpid(): + _append_helper_log( + f"[helper] killing lock holder pid={holder_pid} ipc={args.ipc}" + ) + _windows_kill_pids([holder_pid]) + else: + # Fallback: old helpers (pre-PID-in-lock-file) left no PID. + # Scan once by command-line pattern. + sibling_pids = [ + p for p in _windows_list_pipeline_helper_pids(str(args.ipc)) + if p and p != os.getpid() + ] + if sibling_pids: + _append_helper_log( + f"[helper] killing old-style sibling pids={sibling_pids} ipc={args.ipc}" + ) + _windows_kill_pids(sibling_pids) + else: + _append_helper_log( + f"[helper] no identifiable lock holder for ipc={args.ipc}; waiting" + ) + except Exception as exc: + _append_helper_log( + f"[helper] kill failed: {type(exc).__name__}: {exc}" + ) + + time.sleep(0.5) + + try: + _append_helper_log( + f"[helper] version={MEDEIA_MPV_HELPER_VERSION} started ipc={args.ipc}" + ) + try: + _write_lock_file_metadata(str(args.ipc)) + except Exception: + pass + try: + _append_helper_log( + f"[helper] file={Path(__file__).resolve()} cwd={Path.cwd().resolve()}" + ) + except Exception: + pass + try: + runtime_root = _runtime_config_root() + _append_helper_log( + f"[helper] config_root={runtime_root} exists={bool((runtime_root / 'config.conf').exists())}" + ) + except Exception: + pass + debug(f"[mpv-helper] logging to: {_helper_log_path()}") + except Exception: + pass + + # Route SYS.logger output into the helper log file so diagnostics are not + # lost in mpv's console/terminal output. + try: + + class _HelperLogStream: + + def __init__(self) -> None: + self._pending = "" + + def write(self, s: str) -> int: + if not s: + return 0 + text = self._pending + str(s) + lines = text.splitlines(keepends=True) + if lines and not lines[-1].endswith(("\n", "\r")): + self._pending = lines[-1] + lines = lines[:-1] + else: + self._pending = "" + for line in lines: + payload = line.rstrip("\r\n") + if payload: + _append_helper_log("[py] " + payload) + return len(s) + + def flush(self) -> None: + if self._pending: + _append_helper_log("[py] " + self._pending.rstrip("\r\n")) + self._pending = "" + + set_thread_stream(_HelperLogStream()) + except Exception: + pass + + # Prefer a stable repo-local log folder for discoverability. + error_log_dir = _repo_root() / "Log" + try: + error_log_dir.mkdir(parents=True, exist_ok=True) + except Exception: + error_log_dir = Path(tempfile.gettempdir()) + last_error_log = error_log_dir / "medeia-mpv-pipeline-last-error.log" + seen_request_ids: Dict[str, float] = {} + seen_request_ids_lock = threading.Lock() + 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: + if platform.system() == "Windows": + try: + if _windows_list_mpv_pids(str(args.ipc)): + ipc_lost_since = now + _append_helper_log( + f"[helper] ipc still owned by live mpv process; continuing reconnect wait source={source}" + ) + return + except Exception: + pass + _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) + except Exception: + pass + + payload = (text or "").strip() + if not payload: + return None + + stamped = error_log_dir / f"medeia-mpv-pipeline-error-{req_id}.log" + try: + stamped.write_text(payload, encoding="utf-8", errors="replace") + except Exception: + stamped = None + + try: + last_error_log.write_text(payload, encoding="utf-8", errors="replace") + except Exception: + pass + + return str(stamped) if stamped else str(last_error_log) + + def _mark_request_seen(req_id: str) -> bool: + if not req_id: + return False + now = time.time() + cutoff = now - seen_request_ttl_seconds + with seen_request_ids_lock: + expired = [key for key, ts in seen_request_ids.items() if ts < cutoff] + for key in expired: + seen_request_ids.pop(key, None) + if req_id in seen_request_ids: + return False + seen_request_ids[req_id] = now + return True + + def _publish_response(resp: Dict[str, Any]) -> None: + req_id = str(resp.get("id") or "") + ok = _send_helper_command( + [ + "set_property_string", + RESPONSE_PROP, + json.dumps(resp, ensure_ascii=False), + ], + f"response:{req_id or 'unknown'}", + ) + if ok: + _append_helper_log( + f"[response {req_id or '?'}] published success={bool(resp.get('success'))}" + ) + else: + _append_helper_log( + f"[response {req_id or '?'}] publish failed success={bool(resp.get('success'))}" + ) + + def _process_request(raw: Any, source: str) -> bool: + req = _parse_request(raw) + if not req: + try: + if isinstance(raw, str) and raw.strip(): + snippet = raw.strip().replace("\r", "").replace("\n", " ") + if len(snippet) > 220: + snippet = snippet[:220] + "…" + _append_helper_log( + f"[request-raw {source}] could not parse request json: {snippet}" + ) + except Exception: + pass + return False + + req_id = str(req.get("id") or "") + op = str(req.get("op") or "").strip() + data = req.get("data") + pipeline_text = str(req.get("pipeline") or "").strip() + seeds = req.get("seeds") + json_output = bool(req.get("json") or req.get("output_json")) + + if not req_id: + return False + + if not _mark_request_seen(req_id): + return False + + try: + label = pipeline_text if pipeline_text else (op and ("op=" + op) or "(empty)") + _append_helper_log(f"\n[request {req_id} via {source}] {label}") + except Exception: + pass + + with request_processing_lock: + async_dispatch = False + try: + if op: + run = _run_op(op, data) + else: + if not pipeline_text: + return False + if _is_load_url_pipeline(pipeline_text): + async_dispatch = True + run = { + "success": True, + "stdout": "", + "stderr": "", + "error": "", + "table": None, + } + _run_pipeline_background( + pipeline_text, + seeds=seeds, + req_id=req_id, + ) + else: + run = _run_pipeline( + pipeline_text, + seeds=seeds, + json_output=json_output, + ) + + resp = { + "id": req_id, + "success": bool(run.get("success")), + "stdout": run.get("stdout", ""), + "stderr": run.get("stderr", ""), + "error": run.get("error"), + "table": run.get("table"), + "data": run.get("data"), + } + if "choices" in run: + resp["choices"] = run.get("choices") + if "job_id" in run: + resp["job_id"] = run.get("job_id") + if "job" in run: + resp["job"] = run.get("job") + if "status" in run: + resp["status"] = run.get("status") + if "pid" in run: + resp["pid"] = run.get("pid") + if async_dispatch: + resp["info"] = "queued asynchronously" + except Exception as exc: + resp = { + "id": req_id, + "success": False, + "stdout": "", + "stderr": "", + "error": f"{type(exc).__name__}: {exc}", + "table": None, + } + + try: + if op: + extra = "" + if isinstance(resp.get("choices"), list): + extra = f" choices={len(resp.get('choices') or [])}" + _append_helper_log( + f"[request {req_id}] op-finished success={bool(resp.get('success'))}{extra}" + ) + except Exception: + pass + + try: + if resp.get("stdout"): + _append_helper_log("[stdout]\n" + str(resp.get("stdout"))) + if resp.get("stderr"): + _append_helper_log("[stderr]\n" + str(resp.get("stderr"))) + if resp.get("error"): + _append_helper_log("[error]\n" + str(resp.get("error"))) + except Exception: + pass + + if not resp.get("success"): + details = "" + if resp.get("error"): + details += str(resp.get("error")) + if resp.get("stderr"): + details = (details + "\n" if details else "") + str(resp.get("stderr")) + log_path = _write_error_log(details, req_id=req_id) + if log_path: + resp["log_path"] = log_path + + try: + _publish_response(resp) + except Exception: + pass + + if resp.get("success") and isinstance(resp.get("choices"), list): + try: + _publish_store_choices_cached_property(resp.get("choices")) + except Exception: + pass + + return True + + # Connect to mpv's JSON IPC. On Windows, the pipe can exist but reject opens + # briefly during startup; also mpv may create the IPC server slightly after + # the Lua script launches us. Retry until timeout. + connect_deadline = time.time() + max(0.5, float(args.timeout)) + last_connect_error: Optional[str] = None + + client = MPVIPCClient(socket_path=args.ipc, timeout=0.5, silent=True) + while True: + try: + if client.connect(): + _mark_ipc_alive("startup-connect") + break + except Exception as exc: + last_connect_error = f"{type(exc).__name__}: {exc}" + + if time.time() > connect_deadline: + _append_helper_log( + f"[helper] failed to connect ipc={args.ipc} error={last_connect_error or 'timeout'}" + ) + return 2 + + # Keep trying. + time.sleep(0.10) + + use_shared_ipc_client = platform.system() == "Windows" + command_client = None if use_shared_ipc_client else MPVIPCClient(socket_path=str(args.ipc), timeout=0.75, silent=True) + + def _send_helper_command(command: Any, label: str = "") -> bool: + with command_client_lock: + target_client = client if use_shared_ipc_client else command_client + if target_client is None: + return False + try: + if target_client.sock is None: + if use_shared_ipc_client: + _note_ipc_unavailable(f"helper-command-connect:{label or '?'}") + return False + if not target_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 = target_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: + target_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: + target_client.disconnect() + except Exception: + pass + return False + + def _emit_helper_log_to_mpv(payload: str) -> None: + safe = str(payload or "").replace("\r", " ").replace("\n", " ").strip() + if not safe: + return + if len(safe) > 900: + safe = safe[:900] + "..." + try: + client.send_command_no_wait(["print-text", f"medeia-helper: {safe}"]) + except Exception: + return + + global _HELPER_MPV_LOG_EMITTER + _HELPER_MPV_LOG_EMITTER = _emit_helper_log_to_mpv + for backlog_line in list(_HELPER_LOG_BACKLOG): + try: + _emit_helper_log_to_mpv(backlog_line) + except Exception: + break + + # Mark ready ASAP and keep it fresh. + # Use a unix timestamp so the Lua side can treat it as a heartbeat. + last_ready_ts: float = 0.0 + + def _touch_ready() -> None: + nonlocal last_ready_ts + now = time.time() + # Throttle updates to reduce IPC chatter. + if (now - last_ready_ts) < 0.75: + return + try: + _send_helper_command( + ["set_property_string", READY_PROP, str(int(now))], + "ready-heartbeat", + ) + last_ready_ts = now + except Exception: + return + + def _publish_store_choices_cached_property(choices: Any) -> None: + payload = _store_choices_payload(choices) + if not payload: + return + _send_helper_command( + [ + "set_property_string", + "user-data/medeia-store-choices-cached", + payload, + ], + "store-choices-cache", + ) + + def _publish_helper_version() -> None: + if _send_helper_command( + ["set_property_string", VERSION_PROP, MEDEIA_MPV_HELPER_VERSION], + "helper-version", + ): + _append_helper_log( + f"[helper] published helper version {MEDEIA_MPV_HELPER_VERSION}" + ) + + # Mirror mpv's own log messages into our helper log file so debugging does + # not depend on the mpv on-screen console or mpv's log-file. + try: + # IMPORTANT: mpv debug logs can be extremely chatty (especially ytdl_hook) + # and can starve request handling. Default to warn unless explicitly overridden. + level = os.environ.get("MEDEIA_MPV_HELPER_MPVLOG", "").strip() or "warn" + client.send_command_no_wait(["request_log_messages", level]) + _append_helper_log(f"[helper] requested mpv log messages level={level}") + except Exception: + pass + + # De-dup/throttle mpv log-message lines (mpv and yt-dlp can be very chatty). + last_mpv_line: Optional[str] = None + last_mpv_count: int = 0 + last_mpv_ts: float = 0.0 + + def _flush_mpv_repeat() -> None: + nonlocal last_mpv_line, last_mpv_count + if last_mpv_line and last_mpv_count > 1: + _append_helper_log(f"[mpv] (previous line repeated {last_mpv_count}x)") + last_mpv_line = None + last_mpv_count = 0 + + # Observe request property changes. + try: + client.send_command_no_wait( + ["observe_property", + OBS_ID_REQUEST, + REQUEST_PROP, + "string"] + ) + except Exception: + return 3 + + # Mark ready only after the observer is installed to avoid races where Lua + # sends a request before we can receive property-change notifications. + try: + _touch_ready() + _publish_helper_version() + _append_helper_log(f"[helper] ready heartbeat armed prop={READY_PROP}") + except Exception: + pass + + if use_shared_ipc_client: + _append_helper_log( + "[helper] Windows single-client IPC mode enabled; auxiliary heartbeat/poll disabled" + ) + else: + _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). + try: + startup_choices_payload = _run_op("store-choices", None) + startup_choices = ( + startup_choices_payload.get("choices") + if isinstance(startup_choices_payload, + dict) else None + ) + if isinstance(startup_choices, list): + startup_choices = _set_cached_store_choices(startup_choices) + preview = ", ".join(str(x) for x in startup_choices[:50]) + _append_helper_log( + f"[helper] startup store-choices count={len(startup_choices)} items={preview}" + ) + + # Publish to a cached property for Lua to read without IPC request. + try: + _publish_store_choices_cached_property(startup_choices) + _append_helper_log( + "[helper] published store-choices to user-data/medeia-store-choices-cached" + ) + except Exception as exc: + _append_helper_log( + f"[helper] failed to publish store-choices: {type(exc).__name__}: {exc}" + ) + else: + _append_helper_log("[helper] startup store-choices unavailable") + except Exception as exc: + _append_helper_log( + f"[helper] startup store-choices failed: {type(exc).__name__}: {exc}" + ) + + # Also publish config temp directory if available + try: + cfg = load_config() + temp_dir = cfg.get("temp", "").strip() or os.getenv("TEMP") or "/tmp" + if temp_dir: + _send_helper_command( + ["set_property_string", + "user-data/medeia-config-temp", + temp_dir], + "config-temp", + ) + _append_helper_log( + f"[helper] published config temp to user-data/medeia-config-temp={temp_dir}" + ) + except Exception as exc: + _append_helper_log( + f"[helper] failed to publish config temp: {type(exc).__name__}: {exc}" + ) + + # Publish yt-dlp supported domains for Lua menu filtering + try: + plugin_class = get_plugin_class("ytdlp") + domains = [] + if plugin_class is not None: + domains = sorted( + { + str(value).strip().lower() + for value in plugin_class.url_patterns() + if isinstance(value, str) + and str(value).strip() + and "://" not in str(value) + and not str(value).strip().endswith(":") + } + ) + if domains: + # We join them into a space-separated string for Lua to parse easily + domains_str = " ".join(domains) + _send_helper_command( + [ + "set_property_string", + "user-data/medeia-ytdlp-domains-cached", + domains_str + ], + "ytdlp-domains", + ) + _append_helper_log( + f"[helper] published {len(domains)} ytdlp domains for Lua menu filtering" + ) + except Exception as exc: + _append_helper_log( + f"[helper] failed to publish ytdlp domains: {type(exc).__name__}: {exc}" + ) + + try: + _append_helper_log(f"[helper] connected to ipc={args.ipc}") + except Exception: + pass + + 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 + + _mark_ipc_alive("main-read") + + if msg.get("event") == "__eof__": + _request_shutdown("mpv closed ipc stream") + break + + 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 not text: + continue + + # 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() + + 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 + _append_helper_log(line) + except Exception: + pass + continue + + 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 + if command_client is not None: + try: + command_client.disconnect() + except Exception: + pass + try: + client.disconnect() + except Exception: + pass + try: + _release_ipc_lock(_lock_fh, str(args.ipc)) + except Exception: + pass + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/MPV/portable_config/input.conf b/plugins/mpv/portable_config/input.conf similarity index 100% rename from MPV/portable_config/input.conf rename to plugins/mpv/portable_config/input.conf diff --git a/MPV/portable_config/mpv.conf b/plugins/mpv/portable_config/mpv.conf similarity index 98% rename from MPV/portable_config/mpv.conf rename to plugins/mpv/portable_config/mpv.conf index 3ac7bea..961ffd6 100644 --- a/MPV/portable_config/mpv.conf +++ b/plugins/mpv/portable_config/mpv.conf @@ -18,7 +18,7 @@ audio-buffer=2.0 # Ensure uosc texture/icon fonts are discoverable by libass. osd-fonts-dir=~~/scripts/uosc/fonts -sub-fonts-dir=~~/scripts/uosc/ +sub-fonts-dir=~~/scripts/uosc/fonts ontop=yes autofit=45% diff --git a/MPV/portable_config/script-opts/medeia-selected-store.json b/plugins/mpv/portable_config/script-opts/medeia-selected-store.json similarity index 100% rename from MPV/portable_config/script-opts/medeia-selected-store.json rename to plugins/mpv/portable_config/script-opts/medeia-selected-store.json diff --git a/MPV/portable_config/script-opts/medeia-store-cache.json b/plugins/mpv/portable_config/script-opts/medeia-store-cache.json similarity index 100% rename from MPV/portable_config/script-opts/medeia-store-cache.json rename to plugins/mpv/portable_config/script-opts/medeia-store-cache.json diff --git a/MPV/portable_config/script-opts/medeia.conf b/plugins/mpv/portable_config/script-opts/medeia.conf similarity index 100% rename from MPV/portable_config/script-opts/medeia.conf rename to plugins/mpv/portable_config/script-opts/medeia.conf diff --git a/MPV/portable_config/script-opts/uosc.conf b/plugins/mpv/portable_config/script-opts/uosc.conf similarity index 100% rename from MPV/portable_config/script-opts/uosc.conf rename to plugins/mpv/portable_config/script-opts/uosc.conf diff --git a/MPV/portable_config/script-opts/ytdl_hook.conf b/plugins/mpv/portable_config/script-opts/ytdl_hook.conf similarity index 100% rename from MPV/portable_config/script-opts/ytdl_hook.conf rename to plugins/mpv/portable_config/script-opts/ytdl_hook.conf diff --git a/MPV/portable_config/scripts/uosc.lua b/plugins/mpv/portable_config/scripts/uosc.lua similarity index 100% rename from MPV/portable_config/scripts/uosc.lua rename to plugins/mpv/portable_config/scripts/uosc.lua diff --git a/MPV/portable_config/scripts/uosc/fonts/uosc_icons.otf b/plugins/mpv/portable_config/scripts/uosc/fonts/uosc_icons.otf similarity index 100% rename from MPV/portable_config/scripts/uosc/fonts/uosc_icons.otf rename to plugins/mpv/portable_config/scripts/uosc/fonts/uosc_icons.otf diff --git a/MPV/portable_config/scripts/uosc/fonts/uosc_textures.ttf b/plugins/mpv/portable_config/scripts/uosc/fonts/uosc_textures.ttf similarity index 100% rename from MPV/portable_config/scripts/uosc/fonts/uosc_textures.ttf rename to plugins/mpv/portable_config/scripts/uosc/fonts/uosc_textures.ttf diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-darwin b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-darwin similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-darwin rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-darwin diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-linux b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-linux similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-linux rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/bin/ziggy-linux diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/char-conv/zh.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/char-conv/zh.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/char-conv/zh.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/char-conv/zh.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/BufferingIndicator.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/BufferingIndicator.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/BufferingIndicator.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/BufferingIndicator.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Button.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Button.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Button.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Button.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Controls.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Curtain.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Curtain.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Curtain.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Curtain.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/CycleButton.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/CycleButton.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/CycleButton.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/CycleButton.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Element.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Element.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Element.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Element.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Elements.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Elements.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Elements.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Elements.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/ManagedButton.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/ManagedButton.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/ManagedButton.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/ManagedButton.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Menu.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Menu.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Menu.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Menu.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/PauseIndicator.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/PauseIndicator.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/PauseIndicator.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/PauseIndicator.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Speed.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Speed.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Speed.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Speed.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Timeline.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Timeline.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Timeline.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Timeline.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/TopBar.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/TopBar.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/TopBar.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/TopBar.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Updater.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Updater.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Updater.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Updater.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/Volume.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Volume.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/Volume.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/Volume.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/elements/WindowBorder.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/WindowBorder.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/elements/WindowBorder.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/elements/WindowBorder.lua diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/de.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/de.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/de.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/de.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/es.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/es.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/es.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/es.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/fr.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/fr.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/fr.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/fr.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/pl.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/pl.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/pl.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/pl.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/ro.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/ro.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/ro.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/ro.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/ru.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/ru.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/ru.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/ru.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/tr.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/tr.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/tr.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/tr.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/uk.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/uk.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/uk.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/uk.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/intl/zh-hans.json b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/zh-hans.json similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/intl/zh-hans.json rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/intl/zh-hans.json diff --git a/MPV/portable_config/scripts/uosc/scripts/uosc/main.lua b/plugins/mpv/portable_config/scripts/uosc/scripts/uosc/main.lua similarity index 100% rename from MPV/portable_config/scripts/uosc/scripts/uosc/main.lua rename to plugins/mpv/portable_config/scripts/uosc/scripts/uosc/main.lua diff --git a/MPV/portable_config/scripts/uosc/uosc.conf b/plugins/mpv/portable_config/scripts/uosc/uosc.conf similarity index 100% rename from MPV/portable_config/scripts/uosc/uosc.conf rename to plugins/mpv/portable_config/scripts/uosc/uosc.conf diff --git a/MPV/splash.png b/plugins/mpv/splash.png similarity index 100% rename from MPV/splash.png rename to plugins/mpv/splash.png diff --git a/plugins/podcastindex/__init__.py b/plugins/podcastindex/__init__.py index cc0c965..46fd75c 100644 --- a/plugins/podcastindex/__init__.py +++ b/plugins/podcastindex/__init__.py @@ -193,7 +193,7 @@ class PodcastIndex(Provider): feed_url = str(feed_md.get("url") or item0.get("path") or "").strip() try: - from API.podcastindex import PodcastIndexClient + from plugins.podcastindex.api import PodcastIndexClient client = PodcastIndexClient(key, secret) if feed_id: @@ -407,7 +407,7 @@ class PodcastIndex(Provider): return [] try: - from API.podcastindex import PodcastIndexClient + from plugins.podcastindex.api import PodcastIndexClient client = PodcastIndexClient(key, secret) feeds = client.search_byterm(query, max_results=limit) diff --git a/API/podcastindex.py b/plugins/podcastindex/api/__init__.py similarity index 99% rename from API/podcastindex.py rename to plugins/podcastindex/api/__init__.py index 47900ef..0a06b62 100644 --- a/API/podcastindex.py +++ b/plugins/podcastindex/api/__init__.py @@ -15,7 +15,7 @@ import hashlib import time from typing import Any, Dict, List, Optional -from .base import API, ApiError +from API.base import API, ApiError class PodcastIndexError(ApiError): diff --git a/cmdnat/telegram.py b/plugins/telegram/commands.py similarity index 98% rename from cmdnat/telegram.py rename to plugins/telegram/commands.py index dc3cb94..d1199c2 100644 --- a/cmdnat/telegram.py +++ b/plugins/telegram/commands.py @@ -5,11 +5,11 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence from SYS.cmdlet_spec import Cmdlet, CmdletArg +from SYS.command_parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list from SYS.logger import log from SYS.result_table import Table from SYS import pipeline as ctx from ProviderCore.registry import get_plugin -from cmdnat._parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list _TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items" @@ -339,3 +339,5 @@ CMDLET = Cmdlet( ], exec=_run, ) + +COMMANDS = [CMDLET] diff --git a/plugins/tidal/__init__.py b/plugins/tidal/__init__.py index ee53826..6477776 100644 --- a/plugins/tidal/__init__.py +++ b/plugins/tidal/__init__.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse -from API.Tidal import ( +from plugins.tidal.api import ( Tidal as TidalApiClient, build_track_tags, coerce_duration_seconds, diff --git a/API/Tidal.py b/plugins/tidal/api/__init__.py similarity index 97% rename from API/Tidal.py rename to plugins/tidal/api/__init__.py index 10f5aec..ef64322 100644 --- a/API/Tidal.py +++ b/plugins/tidal/api/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional, Set -from .base import API, ApiError +from API.base import API, ApiError from SYS.logger import debug, debug_panel DEFAULT_BASE_URL = "https://tidal-api.binimum.org" @@ -268,14 +268,14 @@ class Tidal(API): # 1. Fetch info (metadata) - fetch raw to ensure all fields are available for merging info_resp = self._get_json("info/", params={"id": track_int}) - _debug_payload_summary("API.Tidal info", info_resp) + _debug_payload_summary("plugins.tidal.api info", info_resp) info_data = info_resp.get("data") if isinstance(info_resp, dict) else info_resp if not isinstance(info_data, dict) or "id" not in info_data: info_data = info_resp if isinstance(info_resp, dict) and "id" in info_resp else {} # 2. Fetch track (manifest/bit depth) track_resp = self.track(track_id) - _debug_payload_summary("API.Tidal track", track_resp) + _debug_payload_summary("plugins.tidal.api track", track_resp) # Note: track() method in this class currently returns raw JSON, so we handle it similarly. track_data = track_resp.get("data") if isinstance(track_resp, dict) else track_resp if not isinstance(track_data, dict): @@ -285,7 +285,7 @@ class Tidal(API): lyrics_data = {} try: lyr_resp = self.lyrics(track_id) - _debug_payload_summary("API.Tidal lyrics", lyr_resp) + _debug_payload_summary("plugins.tidal.api lyrics", lyr_resp) lyrics_data = lyr_resp.get("lyrics") or lyr_resp if isinstance(lyr_resp, dict) else {} except Exception: pass @@ -309,7 +309,7 @@ class Tidal(API): "lyrics": lyrics_data, } debug_panel( - "API.Tidal full track metadata", + "plugins.tidal.api full track metadata", [ ("track_id", track_int), ("metadata_keys", len(merged_md)), diff --git a/readme.md b/readme.md index f4f78f2..7e114cd 100644 --- a/readme.md +++ b/readme.md @@ -20,6 +20,7 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of

CONTENTS

FEATURES
INSTALLATION
+TAG TEMPLATE SYNTAX
CONFIG
HYDRUS NETWORK
COOKIES
@@ -38,6 +39,7 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of
  • Optional stacks: Telethon (Telegram), aioslsk (Soulseek), and the FlorenceVision tooling install automatically when you configure the corresponding plugin/tool blocks.
  • MPV Manager: Play audio, video, and even images in a custom designed MPV with trimming, screenshotting, and more built right in!
  • Supports remote access and networked setups for offsite servers and sharing workflows.
  • +
  • Reusable tag templates: derive new tags from existing ones with placeholder and padding syntax documented in docs/tag_template_syntax.md.
  • int: def _ensure_repo_available() -> bool: """Prompt for a clone location when running outside the repository.""" nonlocal repo_root, script_dir, is_in_repo + repo_dir_name = "Medios-Macina" # If we have already settled on a repository path in this session, skip. if is_in_repo and repo_root is not None: @@ -1099,6 +1100,14 @@ def main() -> int: print("Error: Could not determine installation path.", file=sys.stderr) return False + if install_path.exists() and install_path.is_dir() and not _is_valid_mm_repo(install_path): + try: + has_contents = any(install_path.iterdir()) + except Exception: + has_contents = False + if has_contents: + install_path = install_path / repo_dir_name + except (EOFError, KeyboardInterrupt): return False