From 1d0de1118be3ef0e3cf1b4d94eda77ab5dabc0a0 Mon Sep 17 00:00:00 2001 From: Nose Date: Wed, 11 Feb 2026 18:16:07 -0800 Subject: [PATCH] f --- .gitignore | 1 + API/HTTP.py | 135 +-------- API/data/alldebrid.json | 16 +- API/httpx_shared.py | 58 ++++ API/requests_client.py | 68 +++++ API/ssl_certs.py | 109 ++++++++ Provider/HIFI.py | 14 +- Provider/Tidal.py | 70 ++++- Provider/internetarchive.py | 2 +- Provider/libgen.py | 35 +-- Provider/matrix.py | 18 +- Provider/metadata_provider.py | 15 +- Provider/openlibrary.py | 21 +- Provider/tidal_manifest.py | 284 +++++++++++++++++++ Provider/torrent.py | 6 +- ProviderCore/base.py | 33 +++ ProviderCore/registry.py | 226 +++++++++------ SYS/field_access.py | 37 +++ SYS/pipe_object.py | 265 ++++++++++++++++++ SYS/pipeline.py | 10 +- SYS/worker_manager.py | 8 +- TUI/modalscreen/download.py | 4 +- cmdlet/_shared.py | 241 +--------------- cmdlet/add_file.py | 8 - cmdlet/search_file.py | 55 ++-- cmdnat/pipe.py | 3 +- mpv_logs_with_db.txt | 500 ---------------------------------- 27 files changed, 1167 insertions(+), 1075 deletions(-) create mode 100644 API/httpx_shared.py create mode 100644 API/requests_client.py create mode 100644 API/ssl_certs.py create mode 100644 Provider/tidal_manifest.py create mode 100644 SYS/field_access.py create mode 100644 SYS/pipe_object.py delete mode 100644 mpv_logs_with_db.txt diff --git a/.gitignore b/.gitignore index 3d3ce31..fd9b587 100644 --- a/.gitignore +++ b/.gitignore @@ -224,6 +224,7 @@ MPV/ffmpeg/* Log/ Log/medeia_macina/telegram.session +mpv_logs_with_db.txt *.session example.py test* diff --git a/API/HTTP.py b/API/HTTP.py index f899cba..6c87892 100644 --- a/API/HTTP.py +++ b/API/HTTP.py @@ -32,122 +32,8 @@ except Exception: # pragma: no cover - optional dependency logger = logging.getLogger(__name__) - -def _resolve_verify_value(verify_ssl: bool) -> Union[bool, str]: - """Return the httpx verify argument, preferring system-aware bundles. - - Order of precedence: - 1. If verify_ssl is not True (False or path), return it. - 2. Respect existing SSL_CERT_FILE env var if present. - 3. Prefer `pip_system_certs` if present and it exposes a bundle path. - 4. Prefer `certifi_win32`/similar helpers by invoking them and reading certifi.where(). - 5. Fall back to `certifi.where()` if available. - 6. Otherwise, return True to let httpx use system defaults. - """ - if verify_ssl is not True: - return verify_ssl - - env_cert = os.environ.get("SSL_CERT_FILE") - if env_cert: - return env_cert - - def _try_module_bundle(mod_name: str) -> Optional[str]: - # Prefer checking sys.modules first (helps test injection / monkeypatching) - mod = sys.modules.get(mod_name) - if mod is None: - # Avoid raising ModuleNotFoundError so debuggers and callers aren't interrupted. - # Check for module availability before attempting to import it. - try: - import importlib.util - - spec = importlib.util.find_spec(mod_name) - if spec is None: - return None - import importlib - - mod = importlib.import_module(mod_name) - except Exception: - # Treat any import/initialization failure as module not available. - return None - - # Common APIs that return a bundle path - for attr in ("where", "get_ca_bundle", "bundle_path", "get_bundle_path", "get_bundle"): - fn = getattr(mod, attr, None) - if callable(fn): - try: - res = fn() - if res: - return res - except Exception: - continue - elif isinstance(fn, str) and fn: - return fn - - # Some helpers (e.g., certifi_win32) expose an action to merge system certs - for call_attr in ("add_windows_store_certs", "add_system_certs", "merge_system_certs"): - fn = getattr(mod, call_attr, None) - if callable(fn): - try: - fn() - try: - import certifi as _certifi - - res = _certifi.where() - if res: - return res - except Exception: - logger.exception("Failed while probing certifi helper inner block") - except Exception: - logger.exception("Failed while invoking cert helper function") - return None - - # Prefer helpful modules if available (use safe checks to avoid first-chance import errors) - for mod_name in ("pip_system_certs", "certifi_win32"): - path = _try_module_bundle(mod_name) - if path: - try: - os.environ["SSL_CERT_FILE"] = path - except Exception: - logger.exception("Failed to set SSL_CERT_FILE environment variable") - logger.info(f"SSL_CERT_FILE not set; using bundle from {mod_name}: {path}") - return path - - # Fallback to certifi - try: - import certifi # type: ignore - - path = certifi.where() - if path: - try: - os.environ["SSL_CERT_FILE"] = path - except Exception: - logger.exception("Failed to set SSL_CERT_FILE environment variable during certifi fallback") - logger.info(f"SSL_CERT_FILE not set; using certifi bundle: {path}") - return path - except Exception: - logger.exception("Failed to probe certifi for trust bundle") - - # Fallback to certifi - try: - import certifi # type: ignore - - path = certifi.where() - if path: - try: - os.environ["SSL_CERT_FILE"] = path - except Exception: - logger.exception("Failed to set SSL_CERT_FILE environment variable during certifi fallback") - logger.info(f"SSL_CERT_FILE not set; using certifi bundle: {path}") - return path - except Exception: - logger.exception("Failed to probe certifi for trust bundle") - - return True - - -def get_requests_verify_value(verify_ssl: bool = True) -> Union[bool, str]: - """Expose the verified value for reuse outside of HTTPClient (requests sessions).""" - return _resolve_verify_value(verify_ssl) +from API.ssl_certs import resolve_verify_value as _resolve_verify_value +from API.ssl_certs import get_requests_verify_value # Default configuration DEFAULT_TIMEOUT = 30.0 @@ -444,13 +330,16 @@ class HTTPClient: "HTTPClient must be used with context manager (with statement)" ) - # Merge headers - if "headers" in kwargs and kwargs["headers"]: - headers = self._get_headers() - headers.update(kwargs["headers"]) - kwargs["headers"] = headers - else: - kwargs["headers"] = self._get_headers() + # Merge headers once per call (do not rebuild for every retry attempt). + merged_headers = self._get_headers() + extra_headers = kwargs.get("headers") + if extra_headers: + try: + merged_headers.update(extra_headers) + except Exception: + # If headers is not a mapping, keep it as-is and let httpx raise. + merged_headers = extra_headers + kwargs["headers"] = merged_headers last_exception: Exception | None = None diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 89f522a..41eddc3 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -37,7 +37,7 @@ "(rapidgator\\.net/file/[0-9]{7,8})" ], "regexp": "((rapidgator\\.net|rg\\.to|rapidgator\\.asia)/file/([0-9a-zA-Z]{32}))|((rapidgator\\.net/file/[0-9]{7,8}))", - "status": false + "status": true }, "turbobit": { "name": "turbobit", @@ -222,20 +222,6 @@ ], "regexp": "(dailyuploads\\.net/[0-9a-zA-Z]{12})" }, - "ddl": { - "name": "ddl", - "type": "premium", - "domains": [ - "ddl.to", - "ddownload.com" - ], - "regexps": [ - "(ddownload\\.com/[0-9a-zA-Z]{12})", - "ddl\\.to/([0-9a-zA-Z]{12})" - ], - "regexp": "((ddownload\\.com/[0-9a-zA-Z]{12}))|(ddl\\.to/([0-9a-zA-Z]{12}))", - "status": false - }, "dropapk": { "name": "dropapk", "type": "premium", diff --git a/API/httpx_shared.py b/API/httpx_shared.py new file mode 100644 index 0000000..aef1bc1 --- /dev/null +++ b/API/httpx_shared.py @@ -0,0 +1,58 @@ +"""Shared `httpx.Client` helper. + +Creating short-lived httpx clients disables connection pooling and costs extra CPU. +This module provides a small singleton client for callers that just need basic +GETs without the full HTTPClient wrapper. +""" + +from __future__ import annotations + +import threading +from typing import Dict, Optional + +import httpx + +from API.ssl_certs import resolve_verify_value + +_DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0 Safari/537.36" +) + +_lock = threading.Lock() +_shared_client: Optional[httpx.Client] = None + + +def get_shared_httpx_client( + *, + timeout: float = 30.0, + verify_ssl: bool = True, + headers: Optional[Dict[str, str]] = None, +) -> httpx.Client: + """Return a process-wide shared synchronous httpx.Client.""" + + global _shared_client + if _shared_client is None: + with _lock: + if _shared_client is None: + base_headers = {"User-Agent": _DEFAULT_USER_AGENT} + if headers: + base_headers.update({str(k): str(v) for k, v in headers.items()}) + _shared_client = httpx.Client( + timeout=timeout, + verify=resolve_verify_value(verify_ssl), + headers=base_headers, + ) + + return _shared_client + + +def close_shared_httpx_client() -> None: + global _shared_client + client = _shared_client + _shared_client = None + if client is not None: + try: + client.close() + except Exception: + pass diff --git a/API/requests_client.py b/API/requests_client.py new file mode 100644 index 0000000..8eadcd1 --- /dev/null +++ b/API/requests_client.py @@ -0,0 +1,68 @@ +"""Shared `requests` session helper. + +Many providers still use `requests` directly. Reusing a Session provides: +- Connection pooling (fewer TCP/TLS handshakes) +- Lower CPU overhead per request + +This module intentionally avoids importing the heavy httpx-based stack. +""" + +from __future__ import annotations + +import threading +from typing import Any, Dict, Optional + +import requests +from requests.adapters import HTTPAdapter + +from API.ssl_certs import resolve_verify_value + +_DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0 Safari/537.36" +) + +_local = threading.local() + + +def get_requests_session( + *, + user_agent: str = _DEFAULT_USER_AGENT, + verify_ssl: bool = True, + pool_connections: int = 16, + pool_maxsize: int = 16, +) -> requests.Session: + """Return a thread-local shared Session configured for pooling.""" + + session: Optional[requests.Session] = getattr(_local, "session", None) + if session is not None: + return session + + session = requests.Session() + session.headers.update({"User-Agent": str(user_agent or _DEFAULT_USER_AGENT)}) + + # Expand connection pool; keep max_retries=0 to avoid semantic changes. + adapter = HTTPAdapter(pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=0) + session.mount("http://", adapter) + session.mount("https://", adapter) + + # Configure verify once. + session.verify = resolve_verify_value(verify_ssl) + + _local.session = session + return session + + +def request( + method: str, + url: str, + *, + params: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[float] = None, + **kwargs: Any, +) -> requests.Response: + """Convenience wrapper around the shared Session.""" + + sess = get_requests_session() + return sess.request(method, url, params=params, headers=headers, timeout=timeout, **kwargs) diff --git a/API/ssl_certs.py b/API/ssl_certs.py new file mode 100644 index 0000000..2c31888 --- /dev/null +++ b/API/ssl_certs.py @@ -0,0 +1,109 @@ +"""SSL certificate bundle resolution helpers. + +This module is intentionally lightweight (no httpx import) so it can be used by +providers that still rely on `requests` without paying the import cost of the +full HTTP client stack. +""" + +from __future__ import annotations + +import logging +import os +import sys +from typing import Optional, Union + +logger = logging.getLogger(__name__) + + +def resolve_verify_value(verify_ssl: bool) -> Union[bool, str]: + """Return the value suitable for `requests`/`httpx` verify parameters. + + - If verify_ssl is not True (False or a path-like string), it is returned. + - Respects an existing SSL_CERT_FILE env var. + - Tries optional helpers (`pip_system_certs`, `certifi_win32`). + - Falls back to `certifi.where()`. + - Otherwise returns True. + """ + + if verify_ssl is not True: + return verify_ssl + + env_cert = os.environ.get("SSL_CERT_FILE") + if env_cert: + return env_cert + + def _try_module_bundle(mod_name: str) -> Optional[str]: + mod = sys.modules.get(mod_name) + if mod is None: + try: + import importlib.util + + spec = importlib.util.find_spec(mod_name) + if spec is None: + return None + import importlib + + mod = importlib.import_module(mod_name) + except Exception: + return None + + for attr in ("where", "get_ca_bundle", "bundle_path", "get_bundle_path", "get_bundle"): + fn = getattr(mod, attr, None) + if callable(fn): + try: + res = fn() + if res: + return str(res) + except Exception: + continue + elif isinstance(fn, str) and fn: + return fn + + for call_attr in ("add_windows_store_certs", "add_system_certs", "merge_system_certs"): + fn = getattr(mod, call_attr, None) + if callable(fn): + try: + fn() + try: + import certifi as _certifi # type: ignore + + res = _certifi.where() + if res: + return str(res) + except Exception: + logger.exception("Failed while probing certifi helper inner block") + except Exception: + logger.exception("Failed while invoking cert helper function") + return None + + for mod_name in ("pip_system_certs", "certifi_win32"): + path = _try_module_bundle(mod_name) + if path: + try: + os.environ["SSL_CERT_FILE"] = path + except Exception: + logger.exception("Failed to set SSL_CERT_FILE environment variable") + logger.info(f"SSL_CERT_FILE not set; using bundle from {mod_name}: {path}") + return path + + try: + import certifi # type: ignore + + path = certifi.where() + if path: + try: + os.environ["SSL_CERT_FILE"] = path + except Exception: + logger.exception("Failed to set SSL_CERT_FILE environment variable during certifi fallback") + logger.info(f"SSL_CERT_FILE not set; using certifi bundle: {path}") + return str(path) + except Exception: + logger.exception("Failed to probe certifi for trust bundle") + + return True + + +def get_requests_verify_value(verify_ssl: bool = True) -> Union[bool, str]: + """Backwards-friendly alias for call sites that only care about requests.""" + + return resolve_verify_value(verify_ssl) diff --git a/Provider/HIFI.py b/Provider/HIFI.py index 22415ef..3d46675 100644 --- a/Provider/HIFI.py +++ b/Provider/HIFI.py @@ -17,7 +17,8 @@ from API.Tidal import ( stringify, ) from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments -from cmdlet._shared import get_field +from SYS.field_access import get_field +from Provider.tidal_manifest import resolve_tidal_manifest_path from SYS import pipeline as pipeline_context from SYS.logger import debug, log @@ -1178,11 +1179,6 @@ class HIFI(Provider): except Exception: pass - try: - from cmdlet._shared import resolve_tidal_manifest_path - except Exception: - return None - resolved = resolve_tidal_manifest_path({"full_metadata": md, "path": raw_path, "title": getattr(result, "title", "")}) if not resolved: return None @@ -1223,9 +1219,11 @@ class HIFI(Provider): # As a fallback, try downloading the URL directly if it looks like a file. try: - import httpx + from API.httpx_shared import get_shared_httpx_client - resp = httpx.get(resolved_text, timeout=float(getattr(self, "api_timeout", 10.0))) + timeout_val = float(getattr(self, "api_timeout", 10.0)) + client = get_shared_httpx_client(timeout=timeout_val) + resp = client.get(resolved_text, timeout=timeout_val) resp.raise_for_status() content = resp.content direct_path = output_dir / f"{stem}.bin" diff --git a/Provider/Tidal.py b/Provider/Tidal.py index cbe2e70..058c8e5 100644 --- a/Provider/Tidal.py +++ b/Provider/Tidal.py @@ -17,7 +17,8 @@ from API.Tidal import ( stringify, ) from ProviderCore.base import Provider, SearchResult -from cmdlet._shared import get_field +from SYS.field_access import get_field +from Provider.tidal_manifest import resolve_tidal_manifest_path from SYS import pipeline as pipeline_context from SYS.logger import debug, log @@ -144,6 +145,62 @@ class Tidal(Provider): meta["view"] = self._get_view(query, filters) return meta + def postprocess_search_results( + self, + *, + query: str, + results: List[SearchResult], + filters: Optional[Dict[str, Any]] = None, + limit: int = 50, + table_type: str = "", + table_meta: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[SearchResult], Optional[str], Optional[Dict[str, Any]]]: + _ = query + _ = filters + _ = table_type + + # Provider-specific UX: if an artist search yields exactly one artist row, + # auto-expand directly to albums (preserves historical cmdlet behavior). + try: + view = (table_meta or {}).get("view") if isinstance(table_meta, dict) else None + if str(view or "").strip().lower() != "artist": + return results, None, None + except Exception: + return results, None, None + + if not isinstance(results, list) or len(results) != 1: + return results, None, None + + artist_res = results[0] + artist_name = str(getattr(artist_res, "title", "") or "").strip() + artist_md = getattr(artist_res, "full_metadata", None) + + artist_id: Optional[int] = None + if isinstance(artist_md, dict): + raw_id = artist_md.get("artistId") or artist_md.get("id") + try: + artist_id = int(raw_id) if raw_id is not None else None + except Exception: + artist_id = None + + # Use a floor of 200 to keep the expanded album list useful. + want = max(int(limit or 0), 200) + try: + album_results = self._albums_for_artist( + artist_id=artist_id, + artist_name=artist_name, + limit=want, + ) + except Exception: + album_results = [] + + if not album_results: + return results, None, None + + meta_out: Dict[str, Any] = dict(table_meta or {}) if isinstance(table_meta, dict) else {} + meta_out["view"] = "album" + return album_results, "tidal.album", meta_out + def __init__(self, config: Optional[Dict[str, Any]] = None) -> None: super().__init__(config) self.api_urls = self._resolve_api_urls() @@ -1304,11 +1361,6 @@ class Tidal(Provider): except Exception: pass - try: - from cmdlet._shared import resolve_tidal_manifest_path - except Exception: - return None - resolved = resolve_tidal_manifest_path({"full_metadata": md, "path": raw_path, "title": getattr(result, "title", "")}) if not resolved: return None @@ -1349,9 +1401,11 @@ class Tidal(Provider): # As a fallback, try downloading the URL directly if it looks like a file. try: - import httpx + from API.httpx_shared import get_shared_httpx_client - resp = httpx.get(resolved_text, timeout=float(getattr(self, "api_timeout", 10.0))) + timeout_val = float(getattr(self, "api_timeout", 10.0)) + client = get_shared_httpx_client(timeout=timeout_val) + resp = client.get(resolved_text, timeout=timeout_val) resp.raise_for_status() content = resp.content direct_path = output_dir / f"{stem}.bin" diff --git a/Provider/internetarchive.py b/Provider/internetarchive.py index badb25a..f717a7a 100644 --- a/Provider/internetarchive.py +++ b/Provider/internetarchive.py @@ -514,7 +514,7 @@ class InternetArchive(Provider): quiet_mode: bool, ) -> Optional[int]: """Generic hook for download-file to show a selection table for IA items.""" - from cmdlet._shared import get_field as sh_get_field + from SYS.field_access import get_field as sh_get_field return maybe_show_formats_table( raw_urls=[url] if url else [], piped_items=[item] if item else [], diff --git a/Provider/libgen.py b/Provider/libgen.py index f93dc0d..6d43538 100644 --- a/Provider/libgen.py +++ b/Provider/libgen.py @@ -4,6 +4,8 @@ import html as html_std import logging import re import requests + +from API.requests_client import get_requests_session import sys import time from pathlib import Path @@ -294,7 +296,7 @@ def _enrich_book_tags_from_isbn(isbn: str, # 1) OpenLibrary API lookup by ISBN (short timeout, silent failure). try: url = f"https://openlibrary.org/api/books?bibkeys=ISBN:{isbn_clean}&jscmd=data&format=json" - resp = requests.get(url, timeout=4) + resp = get_requests_session().get(url, timeout=4) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and data: @@ -407,14 +409,11 @@ def _fetch_libgen_details_html( try: if timeout is None: timeout = (DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT) - session = requests.Session() - session.headers.update( - { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", - } - ) - with session.get(str(url), stream=True, timeout=timeout) as resp: + session = get_requests_session() + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", + } + with session.get(str(url), stream=True, timeout=timeout, headers=headers) as resp: resp.raise_for_status() ct = str(resp.headers.get("Content-Type", "")).lower() if "text/html" not in ct: @@ -1111,13 +1110,15 @@ class LibgenSearch: """Robust LibGen searcher.""" def __init__(self, session: Optional[requests.Session] = None): - self.session = session or requests.Session() - self.session.headers.update( - { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - } - ) + self.session = session or get_requests_session() + # Ensure a modern browser UA is present without clobbering existing one. + if not any(k.lower() == "user-agent" for k in (self.session.headers or {})): + self.session.headers.update( + { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + ) def _search_libgen_json( self, @@ -1901,7 +1902,7 @@ def download_from_mirror( ) -> Tuple[bool, Optional[Path]]: """Download file from a LibGen mirror URL with optional progress tracking.""" - session = session or requests.Session() + session = session or get_requests_session() # Ensure a modern browser User-Agent is used for downloads to avoid mirror blocks. if not any( k.lower() == "user-agent" diff --git a/Provider/matrix.py b/Provider/matrix.py index daf6679..2f9de50 100644 --- a/Provider/matrix.py +++ b/Provider/matrix.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple from urllib.parse import quote -import requests +from API.requests_client import get_requests_session from ProviderCore.base import Provider, SearchResult from SYS.provider_helpers import TableProviderMixin @@ -189,7 +189,7 @@ def _matrix_health_check(*, if not base: return False, "Matrix homeserver missing" - resp = requests.get(f"{base}/_matrix/client/versions", timeout=5) + resp = get_requests_session().get(f"{base}/_matrix/client/versions", timeout=5) if resp.status_code != 200: return False, f"Homeserver returned {resp.status_code}" @@ -197,7 +197,7 @@ def _matrix_health_check(*, headers = { "Authorization": f"Bearer {access_token}" } - resp = requests.get( + resp = get_requests_session().get( f"{base}/_matrix/client/v3/account/whoami", headers=headers, timeout=5 @@ -234,6 +234,8 @@ class Matrix(TableProviderMixin, Provider): 4. Selection triggers upload of pending files to selected rooms """ + EXPOSE_AS_FILE_PROVIDER = False + @classmethod def config_schema(cls) -> List[Dict[str, Any]]: return [ @@ -388,7 +390,7 @@ class Matrix(TableProviderMixin, Provider): headers = { "Authorization": f"Bearer {token}" } - resp = requests.get( + resp = get_requests_session().get( f"{base}/_matrix/client/v3/joined_rooms", headers=headers, timeout=10 @@ -442,7 +444,7 @@ class Matrix(TableProviderMixin, Provider): # Best-effort room name lookup (safe to fail). try: encoded = quote(room_id, safe="") - name_resp = requests.get( + name_resp = get_requests_session().get( f"{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name", headers=headers, timeout=5, @@ -491,7 +493,7 @@ class Matrix(TableProviderMixin, Provider): total_bytes=int(path.stat().st_size), label="upload" ) - resp = requests.post( + resp = get_requests_session().post( upload_url, headers=headers, data=wrapped, @@ -539,7 +541,7 @@ class Matrix(TableProviderMixin, Provider): send_headers = { "Authorization": f"Bearer {token}" } - send_resp = requests.put(send_url, headers=send_headers, json=payload) + send_resp = get_requests_session().put(send_url, headers=send_headers, json=payload) if send_resp.status_code != 200: raise Exception(f"Matrix send message failed: {send_resp.text}") @@ -588,7 +590,7 @@ class Matrix(TableProviderMixin, Provider): "msgtype": "m.text", "body": message } - send_resp = requests.put(send_url, headers=send_headers, json=payload) + send_resp = get_requests_session().put(send_url, headers=send_headers, json=payload) if send_resp.status_code != 200: raise Exception(f"Matrix send text failed: {send_resp.text}") diff --git a/Provider/metadata_provider.py b/Provider/metadata_provider.py index 4a66b77..e96a812 100644 --- a/Provider/metadata_provider.py +++ b/Provider/metadata_provider.py @@ -4,12 +4,12 @@ from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional, Type, cast import html as html_std import re -import requests import sys import json import subprocess from API.HTTP import HTTPClient +from API.requests_client import get_requests_session from ProviderCore.base import SearchResult try: from Provider.Tidal import Tidal @@ -86,7 +86,7 @@ class ITunesProvider(MetadataProvider): "limit": limit } try: - resp = requests.get( + resp = get_requests_session().get( "https://itunes.apple.com/search", params=params, timeout=10 @@ -137,7 +137,7 @@ class OpenLibraryMetadataProvider(MetadataProvider): else: q = query_clean - resp = requests.get( + resp = get_requests_session().get( "https://openlibrary.org/search.json", params={ "q": q, @@ -243,7 +243,7 @@ class GoogleBooksMetadataProvider(MetadataProvider): q = query_clean try: - resp = requests.get( + resp = get_requests_session().get( "https://www.googleapis.com/books/v1/volumes", params={ "q": q, @@ -369,7 +369,7 @@ class ISBNsearchMetadataProvider(MetadataProvider): url = f"https://isbnsearch.org/isbn/{isbn}" try: - resp = requests.get(url, timeout=10) + resp = get_requests_session().get(url, timeout=10) resp.raise_for_status() html = str(resp.text or "") if not html: @@ -1059,7 +1059,10 @@ def fetch_archive_item_metadata(archive_id: str, ident = str(archive_id or "").strip() if not ident: return {} - resp = requests.get(f"https://archive.org/metadata/{ident}", timeout=int(timeout)) + resp = get_requests_session().get( + f"https://archive.org/metadata/{ident}", + timeout=int(timeout), + ) resp.raise_for_status() data = resp.json() if resp is not None else {} if not isinstance(data, dict): diff --git a/Provider/openlibrary.py b/Provider/openlibrary.py index 9fc693d..e56173a 100644 --- a/Provider/openlibrary.py +++ b/Provider/openlibrary.py @@ -16,7 +16,8 @@ from urllib.parse import urlparse import requests -from API.HTTP import HTTPClient, get_requests_verify_value +from API.HTTP import HTTPClient +from API.requests_client import get_requests_session from ProviderCore.base import Provider, SearchResult from SYS.utils import sanitize_filename from SYS.cli_syntax import get_field, get_free_text, parse_query @@ -27,8 +28,6 @@ from Provider.metadata_provider import ( ) from SYS.utils import unique_path -# Resolve lazily to avoid import-time module checks (prevents debugger first-chance noise) -_ARCHIVE_VERIFY_VALUE = None # will be resolved on first session creation _DEFAULT_ARCHIVE_SCALE = 4 _QUALITY_TO_ARCHIVE_SCALE = { "high": 2, @@ -38,12 +37,7 @@ _QUALITY_TO_ARCHIVE_SCALE = { def _create_archive_session() -> requests.Session: - session = requests.Session() - global _ARCHIVE_VERIFY_VALUE - if _ARCHIVE_VERIFY_VALUE is None: - _ARCHIVE_VERIFY_VALUE = get_requests_verify_value() - session.verify = _ARCHIVE_VERIFY_VALUE - return session + return get_requests_session() try: from Crypto.Cipher import AES # type: ignore @@ -590,10 +584,9 @@ class OpenLibrary(Provider): if not ident: return False, "no-archive-id" try: - resp = requests.get( + resp = get_requests_session().get( f"https://archive.org/metadata/{ident}", timeout=8, - verify=_ARCHIVE_VERIFY_VALUE, ) resp.raise_for_status() data = resp.json() if resp is not None else {} @@ -804,10 +797,9 @@ class OpenLibrary(Provider): """Check for a directly downloadable original PDF in Archive.org metadata.""" try: metadata_url = f"https://archive.org/metadata/{book_id}" - response = requests.get( + response = get_requests_session().get( metadata_url, timeout=6, - verify=_ARCHIVE_VERIFY_VALUE, ) response.raise_for_status() metadata = response.json() @@ -822,11 +814,10 @@ class OpenLibrary(Provider): pdf_url = ( f"https://archive.org/download/{book_id}/{filename.replace(' ', '%20')}" ) - check_response = requests.head( + check_response = get_requests_session().head( pdf_url, timeout=4, allow_redirects=True, - verify=_ARCHIVE_VERIFY_VALUE, ) if check_response.status_code == 200: return True, pdf_url diff --git a/Provider/tidal_manifest.py b/Provider/tidal_manifest.py new file mode 100644 index 0000000..8af3d22 --- /dev/null +++ b/Provider/tidal_manifest.py @@ -0,0 +1,284 @@ +"""Tidal/HIFI manifest helpers. + +This module intentionally lives with the provider code (not cmdlets). +It contains best-effort helpers for turning proxy-provided Tidal "manifest" +values into a playable input reference: +- A local MPD file path (persisted to temp) +- Or a direct URL (when the manifest is JSON with `urls`) + +Callers may pass either a SearchResult-like object (with `.full_metadata`) or +pipeline dicts. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict, Optional + +from API.httpx_shared import get_shared_httpx_client +from SYS.logger import log + + +def resolve_tidal_manifest_path(item: Any) -> Optional[str]: + """Persist the Tidal manifest (MPD) and return a local path or URL. + + Resolution order: + 1) `_tidal_manifest_path` (existing local file) + 2) `_tidal_manifest_url` (existing remote URL) + 3) decode `manifest` and: + - if JSON with `urls`: return the first URL + - if MPD XML: persist under `%TEMP%/medeia/tidal/` and return path + + If `manifest` is missing but a track id exists, the function will attempt a + best-effort fetch from the public proxy endpoints to populate `manifest`. + """ + + metadata: Any = None + if isinstance(item, dict): + metadata = item.get("full_metadata") or item.get("metadata") + else: + metadata = getattr(item, "full_metadata", None) or getattr(item, "metadata", None) + + if not isinstance(metadata, dict): + return None + + existing_path = metadata.get("_tidal_manifest_path") + if existing_path: + try: + resolved = Path(str(existing_path)) + if resolved.is_file(): + return str(resolved) + except Exception: + pass + + existing_url = metadata.get("_tidal_manifest_url") + if existing_url and isinstance(existing_url, str): + candidate = existing_url.strip() + if candidate: + return candidate + + raw_manifest = metadata.get("manifest") + if not raw_manifest: + _maybe_fetch_track_manifest(item, metadata) + raw_manifest = metadata.get("manifest") + if not raw_manifest: + return None + + manifest_str = "".join(str(raw_manifest or "").split()) + if not manifest_str: + return None + + manifest_bytes: bytes + try: + manifest_bytes = base64.b64decode(manifest_str, validate=True) + except Exception: + try: + manifest_bytes = base64.b64decode(manifest_str, validate=False) + except Exception: + try: + manifest_bytes = manifest_str.encode("utf-8") + except Exception: + return None + + if not manifest_bytes: + return None + + head = (manifest_bytes[:1024] or b"").lstrip() + if head.startswith((b"{", b"[")): + return _resolve_json_manifest_urls(metadata, manifest_bytes) + + looks_like_mpd = head.startswith((b" None: + """If we only have a track id, fetch details from the proxy to populate `manifest`.""" + + try: + already = bool(metadata.get("_tidal_track_details_fetched")) + except Exception: + already = False + + track_id = metadata.get("trackId") or metadata.get("id") + + if track_id is None: + try: + if isinstance(item, dict): + candidate_path = item.get("path") or item.get("url") + else: + candidate_path = getattr(item, "path", None) or getattr(item, "url", None) + except Exception: + candidate_path = None + + if candidate_path: + m = re.search( + r"(tidal|hifi):(?://)?track[\\/](\d+)", + str(candidate_path), + flags=re.IGNORECASE, + ) + if m: + track_id = m.group(2) + + if already or track_id is None: + return + + try: + track_int = int(track_id) + except Exception: + track_int = None + + if not track_int or track_int <= 0: + return + + try: + client = get_shared_httpx_client() + + resp = client.get( + "https://tidal-api.binimum.org/track/", + params={"id": str(track_int)}, + timeout=10.0, + ) + resp.raise_for_status() + payload = resp.json() + data = payload.get("data") if isinstance(payload, dict) else None + if isinstance(data, dict) and data: + try: + metadata.update(data) + except Exception: + pass + try: + metadata["_tidal_track_details_fetched"] = True + except Exception: + pass + + if not metadata.get("url"): + try: + resp_info = client.get( + "https://tidal-api.binimum.org/info/", + params={"id": str(track_int)}, + timeout=10.0, + ) + resp_info.raise_for_status() + info_payload = resp_info.json() + info_data = info_payload.get("data") if isinstance(info_payload, dict) else None + if isinstance(info_data, dict) and info_data: + try: + for k, v in info_data.items(): + if k not in metadata: + metadata[k] = v + except Exception: + pass + try: + if info_data.get("url"): + metadata["url"] = info_data.get("url") + except Exception: + pass + except Exception: + pass + + except Exception: + return + + +def _resolve_json_manifest_urls(metadata: Dict[str, Any], manifest_bytes: bytes) -> Optional[str]: + try: + text = manifest_bytes.decode("utf-8", errors="ignore") + payload = json.loads(text) + urls = payload.get("urls") or [] + selected_url = None + for candidate in urls: + if isinstance(candidate, str): + candidate = candidate.strip() + if candidate: + selected_url = candidate + break + if selected_url: + try: + metadata["_tidal_manifest_url"] = selected_url + except Exception: + pass + return selected_url + try: + metadata["_tidal_manifest_error"] = "JSON manifest contained no urls" + except Exception: + pass + log( + f"[tidal] JSON manifest for track {metadata.get('trackId') or metadata.get('id')} had no playable urls", + file=sys.stderr, + ) + except Exception as exc: + try: + metadata["_tidal_manifest_error"] = f"Failed to parse JSON manifest: {exc}" + except Exception: + pass + log( + f"[tidal] Failed to parse JSON manifest for track {metadata.get('trackId') or metadata.get('id')}: {exc}", + file=sys.stderr, + ) + return None + + +def _persist_mpd_bytes(item: Any, metadata: Dict[str, Any], manifest_bytes: bytes) -> Optional[str]: + manifest_hash = str(metadata.get("manifestHash") or "").strip() + track_id = metadata.get("trackId") or metadata.get("id") + + identifier = manifest_hash or hashlib.sha256(manifest_bytes).hexdigest() + identifier_safe = re.sub(r"[^A-Za-z0-9_-]+", "_", identifier)[:64] + if not identifier_safe: + identifier_safe = hashlib.sha256(manifest_bytes).hexdigest()[:12] + + track_safe = "tidal" + if track_id is not None: + track_safe = re.sub(r"[^A-Za-z0-9_-]+", "_", str(track_id))[:32] or "tidal" + + manifest_dir = Path(tempfile.gettempdir()) / "medeia" / "tidal" + try: + manifest_dir.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + filename = f"tidal-{track_safe}-{identifier_safe[:24]}.mpd" + target_path = manifest_dir / filename + + try: + with open(target_path, "wb") as fh: + fh.write(manifest_bytes) + metadata["_tidal_manifest_path"] = str(target_path) + + # Best-effort: propagate back into the caller object/dict. + if isinstance(item, dict): + if item.get("full_metadata") is metadata: + item["full_metadata"] = metadata + elif item.get("metadata") is metadata: + item["metadata"] = metadata + else: + extra = getattr(item, "extra", None) + if isinstance(extra, dict): + extra["_tidal_manifest_path"] = str(target_path) + + return str(target_path) + except Exception: + return None diff --git a/Provider/torrent.py b/Provider/torrent.py index f84d4b9..260f781 100644 --- a/Provider/torrent.py +++ b/Provider/torrent.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional import requests + +from API.requests_client import get_requests_session from ProviderCore.base import Provider, SearchResult from SYS.logger import debug, log try: # Preferred HTML parser @@ -66,7 +68,7 @@ class Scraper: def _get_page(self, page: int) -> List[TorrentInfo]: url, payload = self._request_data(page) try: - resp = requests.get( + resp = get_requests_session().get( url, params=payload, headers=self.headers, @@ -86,7 +88,7 @@ class Scraper: def _parse_detail(self, url: str) -> Optional[str]: # optional override try: - resp = requests.get(url, headers=self.headers, timeout=self.timeout) + resp = get_requests_session().get(url, headers=self.headers, timeout=self.timeout) resp.raise_for_status() return self._parse_detail_response(resp) except Exception: diff --git a/ProviderCore/base.py b/ProviderCore/base.py index 2836a2d..26ea6a2 100644 --- a/ProviderCore/base.py +++ b/ProviderCore/base.py @@ -141,6 +141,10 @@ class Provider(ABC): # Used for dynamically generating config panels (e.g., missing credentials). REQUIRED_CONFIG_KEYS: Sequence[str] = () + # Some providers implement `upload()` but are not intended to be used as + # generic "file host" providers via `add-file -provider ...`. + EXPOSE_AS_FILE_PROVIDER: bool = True + def __init__(self, config: Optional[Dict[str, Any]] = None): self.config = config or {} # Prioritize explicit NAME property for the instance name @@ -233,6 +237,35 @@ class Provider(ABC): normalized = str(query or "").strip() return normalized, {} + def postprocess_search_results( + self, + *, + query: str, + results: List[SearchResult], + filters: Optional[Dict[str, Any]] = None, + limit: int = 50, + table_type: str = "", + table_meta: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[SearchResult], Optional[str], Optional[Dict[str, Any]]]: + """Optional hook for provider-specific result transforms. + + Cmdlets should avoid hardcoding provider quirks. Providers can override + this to: + - expand/replace result sets (e.g., artist -> albums) + - override the table type + - override table metadata + + Returns: + (results, table_type_override, table_meta_override) + """ + + _ = query + _ = filters + _ = limit + _ = table_type + _ = table_meta + return results, None, None + # Standard lifecycle/auth hook. def login(self, **_kwargs: Any) -> bool: return True diff --git a/ProviderCore/registry.py b/ProviderCore/registry.py index c2eed20..ebc7c5b 100644 --- a/ProviderCore/registry.py +++ b/ProviderCore/registry.py @@ -7,6 +7,7 @@ for those plugins. from __future__ import annotations +from functools import lru_cache import importlib import pkgutil import sys @@ -18,7 +19,18 @@ from urllib.parse import urlparse from SYS.logger import log, debug from ProviderCore.base import FileProvider, Provider, SearchProvider, SearchResult -from Provider.soulseek import download_soulseek_file + + +def download_soulseek_file(*args: Any, **kwargs: Any) -> Any: + """Lazy proxy for the soulseek downloader. + + Importing the provider modules can be expensive; keeping this lazy avoids + paying that cost at registry import time. + """ + + from Provider.soulseek import download_soulseek_file as _download + + return _download(*args, **kwargs) @dataclass(frozen=True) @@ -36,7 +48,11 @@ class ProviderInfo: @property def supports_upload(self) -> bool: - return self.provider_class.upload is not Provider.upload + try: + exposed = bool(getattr(self.provider_class, "EXPOSE_AS_FILE_PROVIDER", True)) + except Exception: + exposed = True + return exposed and (self.provider_class.upload is not Provider.upload) class ProviderRegistry: @@ -136,8 +152,8 @@ class ProviderRegistry: return self._modules.add(module_name) - for attr in dir(module): - candidate = getattr(module, attr) + # Iterate module dict directly (faster than dir()+getattr()). + for candidate in vars(module).values(): if not isinstance(candidate, type): continue if not issubclass(candidate, Provider): @@ -182,11 +198,64 @@ class ProviderRegistry: continue self._register_module(module) + # Pick up any Provider subclasses loaded via other mechanisms. + self._sync_subclasses() + + def _try_import_for_name(self, normalized_name: str) -> None: + """Best-effort import for a single provider module. + + This avoids importing every provider module when the caller only needs + one provider (common for CLI usage). + """ + name = str(normalized_name or "").strip().lower() + if not name or not self.package_name: + return + + # Keep behavior consistent with full discovery (which skips hifi). + if name == "hifi": + return + + candidates: List[str] = [name] + if "-" in name: + candidates.append(name.replace("-", "_")) + if "." in name: + candidates.append(name.split(".", 1)[0]) + + for mod_name in candidates: + if not mod_name: + continue + module_path = f"{self.package_name}.{mod_name}" + if module_path in self._modules: + continue + try: + module = importlib.import_module(module_path) + except Exception: + continue + self._register_module(module) + # Pick up subclasses in case the module registers indirectly. + self._sync_subclasses() + return + def get(self, name: str) -> Optional[ProviderInfo]: - self.discover() if not name: return None - return self._lookup.get(self._normalize(name)) + + normalized = self._normalize(name) + info = self._lookup.get(normalized) + if info is not None: + return info + + # If we haven't done a full discovery yet, try importing just the + # module that matches the requested name. + if not self._discovered: + self._try_import_for_name(normalized) + info = self._lookup.get(normalized) + if info is not None: + return info + + # Fall back to full package scan. + self.discover() + return self._lookup.get(normalized) def iter_providers(self) -> Iterable[ProviderInfo]: self.discover() @@ -210,8 +279,14 @@ class ProviderRegistry: _walk(Provider) REGISTRY = ProviderRegistry("Provider") -REGISTRY.discover() -REGISTRY._sync_subclasses() + + +@lru_cache(maxsize=512) +def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]: + try: + return list(provider_class.url_patterns()) + except Exception: + return [] def register_provider( @@ -268,14 +343,67 @@ def _supports_search(provider: Provider) -> bool: def _supports_upload(provider: Provider) -> bool: - return provider.__class__.upload is not Provider.upload - - -def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]: try: - return list(provider_class.url_patterns()) + exposed = bool(getattr(provider.__class__, "EXPOSE_AS_FILE_PROVIDER", True)) except Exception: - return [] + exposed = True + return exposed and (provider.__class__.upload is not Provider.upload) + + +def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]: + if entry is None: + return None + if isinstance(entry, dict): + value = entry.get("value") + text = entry.get("text") or entry.get("label") or value + aliases = entry.get("alias") or entry.get("aliases") or [] + value_str = str(value) if value is not None else (str(text) if text is not None else None) + text_str = str(text) if text is not None else value_str + if not value_str or not text_str: + return None + alias_list = [str(a) for a in aliases if a is not None] + return {"value": value_str, "text": text_str, "aliases": alias_list} + return {"value": str(entry), "text": str(entry), "aliases": []} + + +def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[str, Any]]]: + mapping: Dict[str, List[Dict[str, Any]]] = {} + + base = getattr(provider, "QUERY_ARG_CHOICES", None) + if not isinstance(base, dict): + base = getattr(provider, "INLINE_QUERY_FIELD_CHOICES", None) + + def _merge_from(obj: Any) -> None: + if not isinstance(obj, dict): + return + for key, value in obj.items(): + normalized: List[Dict[str, Any]] = [] + seq = value + try: + if callable(seq): + seq = seq() + except Exception: + seq = value + if isinstance(seq, dict): + seq = seq.get("choices") or seq.get("values") or seq + if isinstance(seq, (list, tuple, set)): + for entry in seq: + n = _normalize_choice_entry(entry) + if n: + normalized.append(n) + if normalized: + mapping[str(key).strip().lower()] = normalized + + _merge_from(base) + + try: + fn = getattr(provider, "inline_query_field_choices", None) + if callable(fn): + _merge_from(fn()) + except Exception: + pass + + return mapping def get_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: @@ -422,7 +550,6 @@ def provider_inline_query_choices( Providers can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or ``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper keeps completion logic simple and reusable. - This helper keeps completion logic simple and reusable. """ pname = str(provider_name or "").strip().lower() @@ -436,73 +563,8 @@ def provider_inline_query_choices( if provider is None: return [] - def _normalize_choice_entry(entry: Any) -> Optional[Dict[str, Any]]: - if entry is None: - return None - if isinstance(entry, dict): - value = entry.get("value") - text = entry.get("text") or entry.get("label") or value - aliases = entry.get("alias") or entry.get("aliases") or [] - value_str = str(value) if value is not None else (str(text) if text is not None else None) - text_str = str(text) if text is not None else value_str - if not value_str or not text_str: - return None - alias_list = [str(a) for a in aliases if a is not None] - return {"value": value_str, "text": text_str, "aliases": alias_list} - # string/other primitives - return {"value": str(entry), "text": str(entry), "aliases": []} - - def _collect_mapping(p) -> Dict[str, List[Dict[str, Any]]]: - mapping: Dict[str, List[Dict[str, Any]]] = {} - base = getattr(p, "QUERY_ARG_CHOICES", None) - if not isinstance(base, dict): - base = getattr(p, "INLINE_QUERY_FIELD_CHOICES", None) - if isinstance(base, dict): - for k, v in base.items(): - normalized: List[Dict[str, Any]] = [] - seq = v - try: - if callable(seq): - seq = seq() - except Exception: - seq = v - if isinstance(seq, dict): - seq = seq.get("choices") or seq.get("values") or seq - if isinstance(seq, (list, tuple, set)): - for entry in seq: - n = _normalize_choice_entry(entry) - if n: - normalized.append(n) - if normalized: - mapping[str(k).strip().lower()] = normalized - try: - fn = getattr(p, "inline_query_field_choices", None) - if callable(fn): - extra = fn() - if isinstance(extra, dict): - for k, v in extra.items(): - normalized: List[Dict[str, Any]] = [] - seq = v - try: - if callable(seq): - seq = seq() - except Exception: - seq = v - if isinstance(seq, dict): - seq = seq.get("choices") or seq.get("values") or seq - if isinstance(seq, (list, tuple, set)): - for entry in seq: - n = _normalize_choice_entry(entry) - if n: - normalized.append(n) - if normalized: - mapping[str(k).strip().lower()] = normalized - except Exception: - pass - return mapping - try: - mapping = _collect_mapping(provider) + mapping = _collect_inline_choice_mapping(provider) if not mapping: return [] @@ -556,7 +618,7 @@ def resolve_inline_filters( if not inline_args: return filters - mapping = _collect_mapping(provider) + mapping = _collect_inline_choice_mapping(provider) transforms = field_transforms or {} for raw_key, raw_val in inline_args.items(): diff --git a/SYS/field_access.py b/SYS/field_access.py new file mode 100644 index 0000000..6292e0c --- /dev/null +++ b/SYS/field_access.py @@ -0,0 +1,37 @@ +"""Lightweight helpers for accessing fields on mixed pipeline objects. + +This intentionally avoids importing cmdlet modules so it can be used from +providers and core pipeline code without pulling in the full cmdlet stack. +""" + +from __future__ import annotations + +from typing import Any, Optional + + +def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any: + """Extract a field from either a dict or object with fallback default. + + - Supports dict.get(field) + - Supports getattr(obj, field) + - If obj is a list, uses the first element + - If obj has `.extra` dict (PipeObject pattern), also checks extra[field] + """ + + if isinstance(obj, list): + if not obj: + return default + obj = obj[0] + + if isinstance(obj, dict): + return obj.get(field, default) + + value = getattr(obj, field, None) + if value is not None: + return value + + extra_val = getattr(obj, "extra", None) + if isinstance(extra_val, dict): + return extra_val.get(field, default) + + return default diff --git a/SYS/pipe_object.py b/SYS/pipe_object.py new file mode 100644 index 0000000..2b3da3a --- /dev/null +++ b/SYS/pipe_object.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from typing import Any, Dict, Optional + +from SYS import models + + +def coerce_to_pipe_object( + value: Any, + default_path: Optional[str] = None, +) -> models.PipeObject: + """Normalize any incoming result to a PipeObject for single-source-of-truth state. + + Uses hash+store canonical pattern. + """ + # Debug: Print ResultItem details if coming from search_file.py + try: + from SYS.logger import is_debug_enabled, debug + + if ( + is_debug_enabled() + and hasattr(value, "__class__") + and value.__class__.__name__ == "ResultItem" + ): + debug("[ResultItem -> PipeObject conversion]") + debug(f" title={getattr(value, 'title', None)}") + debug(f" target={getattr(value, 'target', None)}") + debug(f" hash={getattr(value, 'hash', None)}") + debug(f" media_kind={getattr(value, 'media_kind', None)}") + debug(f" tag={getattr(value, 'tag', None)}") + debug(f" tag_summary={getattr(value, 'tag_summary', None)}") + debug(f" size_bytes={getattr(value, 'size_bytes', None)}") + debug(f" duration_seconds={getattr(value, 'duration_seconds', None)}") + debug(f" relationships={getattr(value, 'relationships', None)}") + debug(f" url={getattr(value, 'url', None)}") + debug( + f" full_metadata keys={list(getattr(value, 'full_metadata', {}).keys()) if hasattr(value, 'full_metadata') and value.full_metadata else []}" + ) + except Exception: + pass + + if isinstance(value, models.PipeObject): + return value + + known_keys = { + "hash", + "store", + "tag", + "title", + "url", + "source_url", + "duration", + "metadata", + "warnings", + "path", + "relationships", + "is_temp", + "action", + "parent_hash", + } + + # Convert common object-like results into a dict so we can preserve fields like + # hash/store/url when they come from result tables (e.g., get-url emits UrlItem). + # + # Priority: + # 1) explicit to_dict() + # 2) best-effort attribute extraction for known PipeObject-ish fields + if hasattr(value, "to_dict"): + value = value.to_dict() + elif not isinstance(value, dict): + try: + obj_map: Dict[str, Any] = {} + for k in ( + "hash", + "store", + "provider", + "prov", + "tag", + "title", + "url", + "source_url", + "duration", + "duration_seconds", + "metadata", + "full_metadata", + "warnings", + "path", + "target", + "relationships", + "is_temp", + "action", + "parent_hash", + "extra", + "media_kind", + ): + if hasattr(value, k): + obj_map[k] = getattr(value, k) + if obj_map: + value = obj_map + except Exception: + pass + + if isinstance(value, dict): + # Extract hash and store (canonical identifiers) + hash_val = value.get("hash") + store_val = value.get("store") or "PATH" + if not store_val or store_val == "PATH": + try: + extra_store = value.get("extra", {}).get("store") + except Exception: + extra_store = None + if extra_store: + store_val = extra_store + + # If no hash, try to compute from path or use placeholder + if not hash_val: + path_val = value.get("path") + if path_val: + try: + from pathlib import Path + + from SYS.utils import sha256_file + + hash_val = sha256_file(Path(path_val)) + except Exception: + hash_val = "unknown" + else: + hash_val = "unknown" + + # Extract title from filename if not provided + title_val = value.get("title") + if not title_val: + path_val = value.get("path") + if path_val: + try: + from pathlib import Path + + title_val = Path(path_val).stem + except Exception: + pass + + extra = {k: v for k, v in value.items() if k not in known_keys} + + # Extract URL: prefer direct url field, then url list + from SYS.metadata import normalize_urls + + url_list = normalize_urls(value.get("url")) + url_val = url_list[0] if url_list else None + if len(url_list) > 1: + extra["url"] = url_list + + # Extract relationships + rels = value.get("relationships") or {} + + # Canonical tag: accept list or single string + tag_val: list[str] = [] + if "tag" in value: + raw_tag = value["tag"] + if isinstance(raw_tag, list): + tag_val = [str(t) for t in raw_tag if t is not None] + elif isinstance(raw_tag, str): + tag_val = [raw_tag] + + # Consolidate path: prefer explicit path key, but NOT target if it's a URL + path_val = value.get("path") + # Only use target as path if it's not a URL (url should stay in url field) + if not path_val and "target" in value: + target = value["target"] + if target and not ( + isinstance(target, str) + and (target.startswith("http://") or target.startswith("https://")) + ): + path_val = target + + # If the path value is actually a URL, move it to url_val and clear path_val + try: + if isinstance(path_val, str) and ( + path_val.startswith("http://") or path_val.startswith("https://") + ): + # Prefer existing url_val if present, otherwise move path_val into url_val + if not url_val: + url_val = path_val + path_val = None + except Exception: + pass + + # Extract media_kind if available + if "media_kind" in value: + extra["media_kind"] = value["media_kind"] + + pipe_obj = models.PipeObject( + hash=hash_val, + store=store_val, + provider=str( + value.get("provider") + or value.get("prov") + or value.get("source") + or extra.get("provider") + or extra.get("source") + or "" + ).strip() + or None, + tag=tag_val, + title=title_val, + url=url_val, + source_url=value.get("source_url"), + duration=value.get("duration") or value.get("duration_seconds"), + metadata=value.get("metadata") or value.get("full_metadata") or {}, + warnings=list(value.get("warnings") or []), + path=path_val, + relationships=rels, + is_temp=bool(value.get("is_temp", False)), + action=value.get("action"), + parent_hash=value.get("parent_hash"), + extra=extra, + ) + + return pipe_obj + + # Fallback: build from path argument or bare value + hash_val = "unknown" + path_val = default_path or getattr(value, "path", None) + url_val: Optional[str] = None + title_val = None + + # If the raw value is a string, treat it as either a URL or a file path. + # This is important for @-selection results that are plain URL strings. + if isinstance(value, str): + s = value.strip() + if s.lower().startswith(("http://", "https://")): + url_val = s + path_val = None + else: + path_val = s + + if path_val and path_val != "unknown": + try: + from pathlib import Path + + from SYS.utils import sha256_file + + path_obj = Path(path_val) + hash_val = sha256_file(path_obj) + # Extract title from filename (without extension) + title_val = path_obj.stem + except Exception: + pass + + # When coming from a raw URL string, mark it explicitly as URL. + # Otherwise treat it as a local path. + store_val = "URL" if url_val else "PATH" + + pipe_obj = models.PipeObject( + hash=hash_val, + store=store_val, + provider=None, + path=str(path_val) if path_val and path_val != "unknown" else None, + title=title_val, + url=url_val, + source_url=url_val, + tag=[], + extra={}, + ) + + return pipe_obj diff --git a/SYS/pipeline.py b/SYS/pipeline.py index abe881f..d22f148 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -1607,7 +1607,7 @@ class PipelineExecutor: item = selected_items[0] url = None try: - from cmdlet._shared import get_field + from SYS.field_access import get_field url = get_field(item, "url") except Exception: @@ -2043,7 +2043,7 @@ class PipelineExecutor: return False, None debug(f"@N: _maybe_run_class_selector returned False, continuing") - from cmdlet._shared import coerce_to_pipe_object + from SYS.pipe_object import coerce_to_pipe_object filtered_pipe_objs = [coerce_to_pipe_object(item) for item in filtered] piped_result = ( @@ -2467,7 +2467,7 @@ class PipelineExecutor: last_items = None if last_items: - from cmdlet._shared import coerce_to_pipe_object + from SYS.pipe_object import coerce_to_pipe_object try: pipe_items = [ @@ -2706,7 +2706,7 @@ class PipelineExecutor: if (next_cmd in {"delete-tag", "delete_tag"} and len(filtered) > 1 and all(_is_tag_row(x) for x in filtered)): - from cmdlet._shared import get_field + from SYS.field_access import get_field tags: List[str] = [] first_hash = None @@ -2739,7 +2739,7 @@ class PipelineExecutor: piped_result = grouped continue - from cmdlet._shared import coerce_to_pipe_object + from SYS.pipe_object import coerce_to_pipe_object filtered_pipe_objs = [ coerce_to_pipe_object(item) for item in filtered diff --git a/SYS/worker_manager.py b/SYS/worker_manager.py index 5e98d97..e1e4de2 100644 --- a/SYS/worker_manager.py +++ b/SYS/worker_manager.py @@ -8,7 +8,7 @@ import logging from pathlib import Path from typing import Optional, Dict, Any, List, Callable, Tuple from datetime import datetime -from threading import Thread, Lock +from threading import Thread, Lock, Event import time from SYS.logger import log @@ -273,6 +273,7 @@ class WorkerManager: self.refresh_callbacks: List[Callable] = [] self.refresh_thread: Optional[Thread] = None self._stop_refresh = False + self._refresh_stop_event = Event() self._lock = Lock() self.worker_handlers: Dict[str, WorkerLoggingHandler] = {} self._worker_last_step: Dict[str, str] = {} @@ -658,6 +659,7 @@ class WorkerManager: f"[WorkerManager] Starting auto-refresh with {self.auto_refresh_interval}s interval" ) self._stop_refresh = False + self._refresh_stop_event.clear() self.refresh_thread = Thread(target=self._auto_refresh_loop, daemon=True) self.refresh_thread.start() @@ -665,6 +667,7 @@ class WorkerManager: """Stop the auto-refresh thread.""" logger.info("[WorkerManager] Stopping auto-refresh") self._stop_refresh = True + self._refresh_stop_event.set() if self.refresh_thread: self.refresh_thread.join(timeout=5) self.refresh_thread = None @@ -679,7 +682,8 @@ class WorkerManager: """Main auto-refresh loop that periodically queries and notifies.""" try: while not self._stop_refresh: - time.sleep(self.auto_refresh_interval) + if self._refresh_stop_event.wait(self.auto_refresh_interval): + break # Check if there are active workers active = self.get_active_workers() diff --git a/TUI/modalscreen/download.py b/TUI/modalscreen/download.py index 8afe32b..be0baa5 100644 --- a/TUI/modalscreen/download.py +++ b/TUI/modalscreen/download.py @@ -1354,7 +1354,7 @@ class DownloadModal(ModalScreen): try: from pathlib import Path - import requests + from API.requests_client import get_requests_session from SYS.config import resolve_output_dir # Create temporary list of playlist items for selection parsing @@ -1385,7 +1385,7 @@ class DownloadModal(ModalScreen): try: logger.info(f"Downloading PDF {idx}/{len(selected_url)}: {url}") - response = requests.get(url, timeout=30) + response = get_requests_session().get(url, timeout=30) response.raise_for_status() # Generate filename from URL diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index 62f6ec3..18f0060 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -2753,243 +2753,14 @@ def register_url_with_local_library( return False -def resolve_tidal_manifest_path(item: Any) -> Optional[str]: - """Persist the Tidal manifest from search results and return a local path.""" - - metadata = None - if isinstance(item, dict): - metadata = item.get("full_metadata") or item.get("metadata") - else: - metadata = getattr(item, "full_metadata", None) or getattr(item, "metadata", None) - - if not isinstance(metadata, dict): +try: + # Provider-specific implementation lives with the provider code. + from Provider.tidal_manifest import resolve_tidal_manifest_path +except Exception: # pragma: no cover + def resolve_tidal_manifest_path(item: Any) -> Optional[str]: + _ = item return None - existing_path = metadata.get("_tidal_manifest_path") - if existing_path: - try: - resolved = Path(str(existing_path)) - if resolved.is_file(): - return str(resolved) - except Exception: - pass - - existing_url = metadata.get("_tidal_manifest_url") - if existing_url and isinstance(existing_url, str): - candidate = existing_url.strip() - if candidate: - return candidate - - raw_manifest = metadata.get("manifest") - if not raw_manifest: - # When piping directly from the Tidal search table, we may only have a track id. - # Fetch track details from the proxy so downstream stages can decode the manifest. - try: - already = bool(metadata.get("_tidal_track_details_fetched")) - except Exception: - already = False - - track_id = metadata.get("trackId") or metadata.get("id") - if track_id is None: - try: - if isinstance(item, dict): - candidate_path = item.get("path") or item.get("url") - else: - candidate_path = getattr(item, "path", None) or getattr(item, "url", None) - except Exception: - candidate_path = None - - if candidate_path: - m = re.search( - r"(tidal|hifi):(?://)?track[\\/](\d+)", - str(candidate_path), - flags=re.IGNORECASE, - ) - if m: - track_id = m.group(2) - - if (not already) and track_id is not None: - try: - track_int = int(track_id) - except Exception: - track_int = None - - if track_int and track_int > 0: - try: - import httpx - - resp = httpx.get( - "https://tidal-api.binimum.org/track/", - params={"id": str(track_int)}, - timeout=10.0, - ) - resp.raise_for_status() - payload = resp.json() - data = payload.get("data") if isinstance(payload, dict) else None - if isinstance(data, dict) and data: - try: - metadata.update(data) - except Exception: - pass - try: - metadata["_tidal_track_details_fetched"] = True - except Exception: - pass - if not metadata.get("url"): - try: - resp_info = httpx.get( - "https://tidal-api.binimum.org/info/", - params={"id": str(track_int)}, - timeout=10.0, - ) - resp_info.raise_for_status() - info_payload = resp_info.json() - info_data = info_payload.get("data") if isinstance(info_payload, dict) else None - if isinstance(info_data, dict) and info_data: - try: - for k, v in info_data.items(): - if k not in metadata: - metadata[k] = v - except Exception: - pass - try: - if info_data.get("url"): - metadata["url"] = info_data.get("url") - except Exception: - pass - except Exception: - pass - except Exception: - pass - - raw_manifest = metadata.get("manifest") - if not raw_manifest: - return None - - manifest_str = "".join(str(raw_manifest or "").split()) - if not manifest_str: - return None - - manifest_bytes: bytes - try: - manifest_bytes = base64.b64decode(manifest_str, validate=True) - except Exception: - try: - manifest_bytes = base64.b64decode(manifest_str, validate=False) - except Exception: - try: - manifest_bytes = manifest_str.encode("utf-8") - except Exception: - return None - - if not manifest_bytes: - return None - - head = (manifest_bytes[:1024] or b"").lstrip() - if head.startswith((b"{", b"[")): - try: - text = manifest_bytes.decode("utf-8", errors="ignore") - payload = json.loads(text) - urls = payload.get("urls") or [] - selected_url = None - for candidate in urls: - if isinstance(candidate, str): - candidate = candidate.strip() - if candidate: - selected_url = candidate - break - if selected_url: - try: - metadata["_tidal_manifest_url"] = selected_url - except Exception: - pass - return selected_url - try: - metadata["_tidal_manifest_error"] = "JSON manifest contained no urls" - except Exception: - pass - log( - f"[tidal] JSON manifest for track {metadata.get('trackId') or metadata.get('id')} had no playable urls", - file=sys.stderr, - ) - except Exception as exc: - try: - metadata["_tidal_manifest_error"] = ( - f"Failed to parse JSON manifest: {exc}" - ) - except Exception: - pass - log( - f"[tidal] Failed to parse JSON manifest for track {metadata.get('trackId') or metadata.get('id')}: {exc}", - file=sys.stderr, - ) - return None - - looks_like_mpd = ( - head.startswith(b" {len(results or [])} result(s)") - # Tidal artist UX: if there is exactly one artist match, auto-expand - # directly to albums without requiring an explicit @1 selection. - if ( - provider_lower == "tidal" - and table_meta.get("view") == "artist" - and isinstance(results, list) - and len(results) == 1 - ): - try: - artist_res = results[0] - artist_name = str(getattr(artist_res, "title", "") or "").strip() - artist_md = getattr(artist_res, "full_metadata", None) - artist_id = None - if isinstance(artist_md, dict): - raw_id = artist_md.get("artistId") or artist_md.get("id") - try: - artist_id = int(raw_id) if raw_id is not None else None - except Exception: - artist_id = None - - album_results = [] - if hasattr(provider, "_albums_for_artist") and callable(getattr(provider, "_albums_for_artist")): - try: - album_results = provider._albums_for_artist( # type: ignore[attr-defined] - artist_id=artist_id, - artist_name=artist_name, - limit=max(int(limit or 0), 200), - ) - except Exception: - album_results = [] - - if album_results: - results = album_results - table_type = "tidal.album" + # Allow providers to apply provider-specific UX transforms (e.g. auto-expansion) + try: + post = getattr(provider, "postprocess_search_results", None) + if callable(post) and isinstance(results, list): + results, table_type_override, table_meta_override = post( + query=query, + results=results, + filters=search_filters or None, + limit=int(limit or 0), + table_type=str(table_type or ""), + table_meta=dict(table_meta) if isinstance(table_meta, dict) else None, + ) + if table_type_override: + table_type = str(table_type_override) table.set_table(table_type) - table_meta["view"] = "album" + if isinstance(table_meta_override, dict) and table_meta_override: + table_meta = dict(table_meta_override) try: table.set_table_metadata(table_meta) except Exception: pass - except Exception: - pass + except Exception: + pass if not results: log(f"No results found for query: {query}", file=sys.stderr) diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index 9118ab3..7a3a9f2 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -8,7 +8,8 @@ import re from datetime import datetime from urllib.parse import urlparse, parse_qs from pathlib import Path -from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args, resolve_tidal_manifest_path +from cmdlet._shared import Cmdlet, CmdletArg, parse_cmdlet_args +from Provider.tidal_manifest import resolve_tidal_manifest_path 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 diff --git a/mpv_logs_with_db.txt b/mpv_logs_with_db.txt deleted file mode 100644 index 87e29d3..0000000 --- a/mpv_logs_with_db.txt +++ /dev/null @@ -1,500 +0,0 @@ -Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z - DEBUG: MPV log file: C:\Forgejo\Medios-Macina\Log\medeia-mpv.log - DEBUG: Named pipe not available yet: \\.\pipe\mpv-medios-macina - DEBUG: Named pipe not available yet: \\.\pipe\mpv-medios-macina -MPV log file: C:\Forgejo\Medios-Macina\Log\medeia-mpv.log - DEBUG: Named pipe not available yet: \\.\pipe\mpv-medios-macina - DEBUG: Named pipe not available yet: \\.\pipe\mpv-medios-macina -MPV log (tail): -[ 34.120][d][ao/wasapi] Fixing format -[ 34.120][d][ao/wasapi] IAudioClient::GetDevicePeriod -[ 34.121][v][ao/wasapi] Device period: default 10000 us, minimum 3000 us -[ 34.121][d][ao/wasapi] IAudioClient::Initialize -[ 34.134][d][ao/wasapi] IAudioClient::Initialize pRenderClient -[ 34.134][d][ao/wasapi] IAudioClient::Initialize IAudioClient_SetEventHandle -[ 34.134][d][ao/wasapi] IAudioClient::Initialize IAudioClient_GetBufferSize -[ 34.134][v][ao/wasapi] Buffer frame count: 1056 (22000 us) -[ 34.134][v][ao/wasapi] IAudioClock::GetFrequency gave a frequency of 384000. -[ 34.136][d][ao/wasapi] IAudioClient::Initialize pAudioVolume -[ 34.136][d][ao/wasapi] Entering dispatch loop -[ 34.136][d][ao/wasapi] Init wasapi done -[ 34.136][v][ao/wasapi] device buffer: 1056 samples. -[ 34.136][v][ao/wasapi] using soft-buffer of 9600 samples. -[ 34.136][i][cplayer] AO: [wasapi] 48000Hz stereo 2ch float -[ 34.136][v][cplayer] AO: Description: Windows WASAPI audio output (event mode) -[ 34.136][v][autoconvert] inserting resampler -[ 34.136][v][swresample] format change, reinitializing resampler -[ 34.136][v][swresample] 44100Hz stereo floatp -> 48000Hz stereo float -[ 34.137][v][af] [out] 48000Hz stereo 2ch float -[ 34.139][v][cplayer] audio ready -[ 34.139][v][cplayer] starting audio playback -[ 34.139][d][ao/wasapi] Thread Resume -[ 34.139][d][ao/wasapi] Thread Reset -[ 34.139][d][ao/wasapi] Thread Pause -[ 34.139][d][ao/wasapi] Thread Unpause -[ 34.139][v][cplayer] playback restart complete @ 0.000000, audio=playing, video=eof -[ 34.140][v][cplayer] Set property: user-data/medeia-pipeline-response="" -> 1 -[ 34.141][v][cplayer] Set property: user-data/medeia-pipeline-request="{\"data\":{\"url\":\"https://www.youtube.com/watch?v=dQw4w9WgXcQ\"},\"op\":\"ytdlp-formats\",\"id\":\"34082-728967\"}" -> 1 -[ 34.148][d][timeline] stream 0: resize index to 256 -[ 34.173][v][lavf] EOF reached. -[ 34.173][v][timeline] EOF reached. -[ 35.393][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 35.393][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 35.393][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 35.399][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 35.399][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 35.399][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 35.484][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 35.484][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 35.484][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 35.490][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 35.490][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 35.490][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 35.962][v][ipc_33] Client connected -[ 35.963][v][cplayer] Set property: options/log-file="C:\\Forgejo\\Medios-Macina\\Log\\medeia-mpv.log" -> 1 -[ 35.965][v][ipc_33] Client disconnected -[ 35.965][d][ipc_33] Destroying client handle... -[ 35.966][v][ipc_34] Client connected -[ 35.967][v][cplayer] Set property: options/msg-level="all=v" -> 1 -[ 35.969][v][ipc_34] Client disconnected -[ 35.969][d][ipc_34] Destroying client handle... -[ 35.970][v][ipc_35] Client connected -[ 35.971][i][cplayer] medeia: log enabled -> C:\Forgejo\Medios-Macina\Log\medeia-mpv.log -[ 35.972][v][ipc_35] Client disconnected -[ 35.972][d][ipc_35] Destroying client handle... -[ 35.973][v][ipc_36] Client connected -[ 35.973][v][ipc_36] Client disconnected -[ 35.973][v][ipc_37] Client connected -[ 35.973][d][ipc_36] Destroying client handle... -[ 35.974][v][cplayer] Set property: options/log-file="C:\\Forgejo\\Medios-Macina\\Log\\medeia-mpv.log" -> 1 -[ 35.975][v][ipc_37] Client disconnected -[ 35.975][d][ipc_37] Destroying client handle... -[ 35.975][v][ipc_38] Client connected -[ 35.977][v][cplayer] Set property: options/msg-level="all=v" -> 1 -[ 35.978][v][ipc_38] Client disconnected -[ 35.979][d][ipc_38] Destroying client handle... -[ 35.980][v][ipc_39] Client connected -[ 35.981][v][cplayer] Set property: options/log-file="C:\\Forgejo\\Medios-Macina\\Log\\medeia-mpv.log" -> 1 -[ 35.983][v][ipc_39] Client disconnected -[ 35.983][d][ipc_39] Destroying client handle... -[ 35.984][v][ipc_40] Client connected -[ 35.985][v][cplayer] Set property: options/msg-level="all=v" -> 1 -[ 35.986][v][ipc_40] Client disconnected -[ 35.986][d][ipc_40] Destroying client handle... -[ 35.986][v][ipc_41] Client connected -[ 35.987][i][cplayer] medeia: log enabled -> C:\Forgejo\Medios-Macina\Log\medeia-mpv.log -[ 35.989][v][ipc_41] Client disconnected -[ 35.989][d][ipc_41] Destroying client handle... -[ 36.180][d][cplayer] Run command: cycle, flags=73, args=[name="pause", value="1.000000"] -[ 36.180][v][cplayer] Set property: pause -> 1 -[ 36.180][d][ao/wasapi] Thread Pause -[ 37.104][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 37.104][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 37.104][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 37.576][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 37.583][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 37.583][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 38.287][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 38.287][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 38.287][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 38.693][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 38.693][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 38.693][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 38.965][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 38.965][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 38.965][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 38.984][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 38.990][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 38.990][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 39.098][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 39.098][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 39.105][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 39.112][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 39.112][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 39.112][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 41.106][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 41.106][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 41.106][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 41.115][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 41.122][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 41.122][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 42.198][d][cplayer] Run command: begin-vo-dragging, flags=73, args=[] -[ 42.814][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 42.814][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 42.828][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 42.828][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 42.835][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 42.835][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_right", flags="allow-hide-cursor+allow-vo-dragging"] -[ 42.835][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 42.835][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 42.856][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 42.856][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_right"] -[ 42.856][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 42.856][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 44.274][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 44.274][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_right", flags="allow-hide-cursor+allow-vo-dragging"] -[ 44.274][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 44.274][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 44.283][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 44.283][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_right"] -[ 44.283][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 44.283][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 44.297][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 44.297][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 44.297][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 44.305][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 44.305][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 44.305][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 45.258][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 45.258][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 45.258][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 45.264][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 45.270][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 45.271][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 46.370][d][cplayer] Run command: begin-vo-dragging, flags=73, args=[] -[ 48.743][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 48.744][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 50.556][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 50.556][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 50.682][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 50.682][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 50.696][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 50.696][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 50.710][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 50.710][d][cplayer] Run command: enable-section, flags=64, args=[name="wheel", flags="allow-hide-cursor+allow-vo-dragging"] -[ 50.710][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 50.786][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left_dbl"] -[ 50.786][d][cplayer] Run command: disable-section, flags=64, args=[name="wheel"] -[ 50.786][d][cplayer] Run command: disable-section, flags=64, args=[name="mbtn_left"] -[ 52.125][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left_dbl", flags=""] -[ 52.125][d][cplayer] Run command: enable-section, flags=64, args=[name="mbtn_left", flags=""] -[ 52.788][d][cplayer] Run command: script-binding, flags=73, args=[name="uosc/__keybinding1", arg=""] -[ 52.789][d][cplayer] Run command: quit, flags=73, args=[code="0"] -[ 52.789][v][cplayer] EOF code: 5 -[ 52.789][d][ad] Uninit decoder. -[ 52.789][d][ao/wasapi] Thread Reset -[ 52.789][d][ao/wasapi] Thread Pause -[ 52.790][d][cplayer] Terminating demuxers... -[ 52.791][d][ffmpeg] AVIOContext: Statistics: 1300631 bytes read, 0 seeks -[ 52.791][d][cplayer] Done terminating demuxers. -[ 52.791][v][cplayer] finished playback, success (reason 3) -[ 52.791][v][cplayer] Running hook: ytdl_hook/on_after_end_file -[ 52.792][v][auto_profiles] Re-evaluating auto profile manga -[ 52.793][v][auto_profiles] Re-evaluating auto profile loop-short -[ 52.793][d][cplayer] Run command: del, flags=64, args=[name="user-data/mpv/ytdl/json-subprocess-result"] -[ 52.793][i][cplayer] Exiting... (Quit) -[ 52.794][d][cplayer] Run command: del, flags=64, args=[name="user-data/mpv/console"] -[ 52.794][d][ipc_8] Destroying client handle... -[ 52.794][d][positioning] Destroying client handle... -[ 52.794][d][commands] Destroying client handle... -[ 52.794][d][select] Destroying client handle... -[ 52.794][d][ipc_3] Destroying client handle... -[ 52.794][d][ipc_32] Destroying client handle... -[ 52.794][d][cplayer] Run command: keybind, flags=64, args=[name="q", cmd="quit", comment=""] -[ 52.794][d][console] Destroying client handle... -[ 52.794][d][auto_profiles] Destroying client handle... -[ 52.795][d][stats] Destroying client handle... -[ 52.796][d][main] Destroying client handle... -[ 52.796][d][ytdl_hook] Destroying client handle... -[ 52.798][d][uosc] Destroying client handle... -[ 52.799][d][SystemMediaTransportControls] Destroying client handle... -[ 52.805][d][ao/wasapi] Uninit wasapi -[ 52.805][d][ao/wasapi] Thread Reset -[ 52.805][d][ao/wasapi] Thread Pause -[ 52.805][d][ao/wasapi] Thread shutdown -[ 52.805][d][ao/wasapi] Thread uninit done -[ 52.805][d][ao/wasapi] Thread return -[ 52.806][d][ao/wasapi] Uninit wasapi done -[ 52.851][v][vo/gpu-next/win32] uninit -Helper logs from database (mpv module, most recent first): -[ERROR] Load URL pipeline failed: timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[WARN] Load URL still processing after 5 seconds -[INFO] [mpv error] ytdl_hook ERROR: Unsupported URL: https://example.com/ -[INFO] [mpv error] ytdl_hook youtube-dl failed: unexpected error occurred -[INFO] [mpv error] cplayer Failed to recognize file format. -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [mpv error] vo/gpu-next/libplacebo Failed creating FBO texture! Disabling advanced rendering.. -[INFO] [mpv error] vo/gpu-next/libplacebo Failed dispatching scaler.. disabling -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [mpv] (previous line repeated 4x) -[INFO] [mpv warn] input No key binding found for key ';'. -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv] (previous line repeated 2x) -[INFO] [mpv] (previous line repeated 4x) -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv] (previous line repeated 4x) -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv] (previous line repeated 2x) -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv] (previous line repeated 3x) -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv] (previous line repeated 3x) -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [mpv] (previous line repeated 4x) -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [mpv warn] input No key binding found for key 'MBTN_RIGHT_DBL'. -[INFO] [helper] version=2025-12-19 started ipc=\\.\pipe\mpv-medios-macina -[INFO] [helper] file=C:\Forgejo\Medios-Macina\MPV\pipeline_helper.py cwd=C:\Forgejo\Medios-Macina -[INFO] [helper] config_root=C:\Forgejo\Medios-Macina exists=False -[INFO] [helper] requested mpv log messages level=warn -[INFO] [helper] ready heartbeat armed prop=user-data/medeia-pipeline-ready -[INFO] [py] Loaded config from medios.db: providers=4 (alldebrid, soulseek, matrix, -[INFO] [py] telegram), stores=2 (hydrusnetwork, debrid), mtime=2026-02-02T02:46:41.638481Z -[INFO] [py] DEBUG: config_dir=C:\Forgejo\Medios-Macina choices=2 -[INFO] [helper] startup store-choices count=2 items=local, rpi -[INFO] [helper] published store-choices to user-data/medeia-store-choices-cached -[INFO] [helper] published config temp to user-data/medeia-config-temp=C:\Users\Admin\AppData\Local\Temp -[INFO] [helper] connected to ipc=\\.\pipe\mpv-medios-macina -[INFO] [mpv] (previous line repeated 3x) -Helper log file: C:\Forgejo\Medios-Macina\Log\medeia-mpv-helper.log -Helper log (tail): -[lua] [2026-02-04 16:56:11] medeia lua loaded version=2025-12-24 script=main -[lua] [2026-02-04 16:56:11] medeia-lua loaded version=2025-12-24 -[lua] [2026-02-04 16:56:11] stores: cache_read cached_json=nil len=0 -[lua] [2026-02-04 16:56:11] stores: cache_empty cached_json=nil -[lua] [2026-02-04 16:56:11] stores: requesting store-choices via helper (fallback) -[lua] [2026-02-04 16:56:11] stores: failed to load store choices via helper; success=false choices_type=nil stderr= error=helper not running -[lua] [2026-02-04 16:56:12] [KEY] attempting to re-register mbtn_right after UOSC loaded -[lua] [2026-02-04 16:56:26] [input.conf] medios-show-menu called -[lua] [2026-02-04 16:56:26] [MENU] M.show_menu called -[lua] [2026-02-04 16:56:26] [MENU] Built 6 menu items -[lua] [2026-02-04 16:56:26] [MENU] ensure_uosc_loaded returned: true -[lua] [2026-02-04 16:56:26] [MENU] Sending menu JSON to uosc: {"title":"Medios Macina","items":[{"title":"Load URL","value":"script-message medios-load-url"},{"value":"script-binding medios-info","title":"Get Metadata","hint":"Ctrl+i"},{"value":"script-binding m... -[lua] [2026-02-04 16:56:26] [MENU] Menu command sent successfully -[lua] [2026-02-04 16:56:27] medios-load-url handler called -[lua] [2026-02-04 16:56:27] medios-load-url: closing main menu before opening Load URL prompt -[lua] [2026-02-04 16:56:27] open_load_url_prompt called -[lua] [2026-02-04 16:56:27] open_load_url_prompt: sending menu to uosc -[lua] [2026-02-04 16:56:33] [LOAD-URL] Event handler called with: {"type":"search","query":"https://www.youtube.com/watch?v=3IpPonmYx3g","menu_id":"{root}"} -[lua] [2026-02-04 16:56:33] [LOAD-URL] Parsed event: type=search, query=https://www.youtube.com/watch?v=3IpPonmYx3g -[lua] [2026-02-04 16:56:33] [LOAD-URL] Trimmed URL: "https://www.youtube.com/watch?v=3IpPonmYx3g" -[lua] [2026-02-04 16:56:33] [INFO] Load URL started: https://www.youtube.com/watch?v=3IpPonmYx3g -[lua] [2026-02-04 16:56:33] [LOAD-URL] Starting to load: https://www.youtube.com/watch?v=3IpPonmYx3g -[lua] [2026-02-04 16:56:33] [LOAD-URL] Checking if URL can be loaded directly: false -[lua] [2026-02-04 16:56:33] [LOAD-URL] URL requires pipeline helper for processing -[lua] [2026-02-04 16:56:33] [LOAD-URL] Pipeline helper ready: true -[lua] [2026-02-04 16:56:33] [LOAD-URL] Sending to pipeline: .mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[lua] [2026-02-04 16:56:33] [LOAD-URL] Pipeline helper ready: true -[lua] [2026-02-04 16:56:33] M.run_pipeline called with cmd: .mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[lua] [2026-02-04 16:56:34] ipc-async: send request id=22550-814785 cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[lua] [2026-02-04 16:56:35] [LOAD-URL] Event handler called with: {"type":"close"} -[lua] [2026-02-04 16:56:35] [LOAD-URL] Parsed event: type=close, query=nil -[lua] [2026-02-04 16:56:35] [LOAD-URL] Event type is not search: close -[lua] [2026-02-04 16:56:35] [LOAD-URL] Closing menu due to type mismatch -[lua] [2026-02-04 16:56:38] [WARN] Load URL still processing after 5 seconds -[lua] [2026-02-04 16:56:38] [LOAD-URL] Timeout message shown (helper still processing) -[lua] [2026-02-04 16:56:44] M.run_pipeline callback fired: resp=nil, err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[lua] [2026-02-04 16:56:44] pipeline failed cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[lua] [2026-02-04 16:56:44] [LOAD-URL] Pipeline callback received: resp=nil, err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[lua] [2026-02-04 16:56:44] [LOAD-URL] Pipeline error: timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[lua] [2026-02-04 16:56:44] [ERROR] Load URL pipeline failed: timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[lua] [2026-02-04 16:56:44] [LOAD-URL] Closing menu -[lua] [2026-02-04 16:56:44] [LOAD-URL] Sending close-menu command to UOSC -[lua] [2026-02-04 16:56:45] ipc-async: send request id=34082-728967 op=ytdlp-formats -Lua log file: C:\Forgejo\Medios-Macina\Log\medeia-mpv-lua.log -Lua log (tail): -[2026-02-04 16:56:11] medeia lua loaded version=2025-12-24 script=main -[2026-02-04 16:56:11] medeia-lua loaded version=2025-12-24 -[2026-02-04 16:56:11] stores: cache_read cached_json=nil len=0 -[2026-02-04 16:56:11] stores: cache_empty cached_json=nil -[2026-02-04 16:56:11] stores: requesting store-choices via helper (fallback) -[2026-02-04 16:56:11] stores: failed to load store choices via helper; success=false choices_type=nil stderr= error=helper not running -[2026-02-04 16:56:12] [KEY] attempting to re-register mbtn_right after UOSC loaded -[2026-02-04 16:56:26] [input.conf] medios-show-menu called -[2026-02-04 16:56:26] [MENU] M.show_menu called -[2026-02-04 16:56:26] [MENU] Built 6 menu items -[2026-02-04 16:56:26] [MENU] ensure_uosc_loaded returned: true -[2026-02-04 16:56:26] [MENU] Sending menu JSON to uosc: {"title":"Medios Macina","items":[{"title":"Load URL","value":"script-message medios-load-url"},{"value":"script-binding medios-info","title":"Get Metadata","hint":"Ctrl+i"},{"value":"script-binding m... -[2026-02-04 16:56:26] [MENU] Menu command sent successfully -[2026-02-04 16:56:27] medios-load-url handler called -[2026-02-04 16:56:27] medios-load-url: closing main menu before opening Load URL prompt -[2026-02-04 16:56:27] open_load_url_prompt called -[2026-02-04 16:56:27] open_load_url_prompt: sending menu to uosc -[2026-02-04 16:56:33] [LOAD-URL] Event handler called with: {"type":"search","query":"https://www.youtube.com/watch?v=3IpPonmYx3g","menu_id":"{root}"} -[2026-02-04 16:56:33] [LOAD-URL] Parsed event: type=search, query=https://www.youtube.com/watch?v=3IpPonmYx3g -[2026-02-04 16:56:33] [LOAD-URL] Trimmed URL: "https://www.youtube.com/watch?v=3IpPonmYx3g" -[2026-02-04 16:56:33] [INFO] Load URL started: https://www.youtube.com/watch?v=3IpPonmYx3g -[2026-02-04 16:56:33] [LOAD-URL] Starting to load: https://www.youtube.com/watch?v=3IpPonmYx3g -[2026-02-04 16:56:33] [LOAD-URL] Checking if URL can be loaded directly: false -[2026-02-04 16:56:33] [LOAD-URL] URL requires pipeline helper for processing -[2026-02-04 16:56:33] [LOAD-URL] Pipeline helper ready: true -[2026-02-04 16:56:33] [LOAD-URL] Sending to pipeline: .mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[2026-02-04 16:56:33] [LOAD-URL] Pipeline helper ready: true -[2026-02-04 16:56:33] M.run_pipeline called with cmd: .mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[2026-02-04 16:56:34] ipc-async: send request id=22550-814785 cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play -[2026-02-04 16:56:35] [LOAD-URL] Event handler called with: {"type":"close"} -[2026-02-04 16:56:35] [LOAD-URL] Parsed event: type=close, query=nil -[2026-02-04 16:56:35] [LOAD-URL] Event type is not search: close -[2026-02-04 16:56:35] [LOAD-URL] Closing menu due to type mismatch -[2026-02-04 16:56:38] [WARN] Load URL still processing after 5 seconds -[2026-02-04 16:56:38] [LOAD-URL] Timeout message shown (helper still processing) -[2026-02-04 16:56:44] M.run_pipeline callback fired: resp=nil, err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[2026-02-04 16:56:44] pipeline failed cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[2026-02-04 16:56:44] [LOAD-URL] Pipeline callback received: resp=nil, err=timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[2026-02-04 16:56:44] [LOAD-URL] Pipeline error: timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[2026-02-04 16:56:44] [ERROR] Load URL pipeline failed: timeout waiting response (cmd=.mpv -url "https://www.youtube.com/watch?v=3IpPonmYx3g" -play) -[2026-02-04 16:56:44] [LOAD-URL] Closing menu -[2026-02-04 16:56:44] [LOAD-URL] Sending close-menu command to UOSC -[2026-02-04 16:56:45] ipc-async: send request id=34082-728967 op=ytdlp-formats