diff --git a/API/HydrusNetwork.py b/API/HydrusNetwork.py index 881c80b..ace54c8 100644 --- a/API/HydrusNetwork.py +++ b/API/HydrusNetwork.py @@ -106,16 +106,7 @@ class HydrusNetwork: def _perform_request(self, spec: HydrusRequestSpec) -> Any: headers: dict[str, str] = {} - - # On first request, try to acquire session key for security - if not self._session_key and self.access_key and spec.endpoint != "/session_key": - try: - logger.debug(f"[Hydrus] Acquiring session key on first request...") - self._acquire_session_key() - except Exception as e: - # If session key acquisition fails, fall back to access key - logger.debug(f"[Hydrus] Session key acquisition failed: {e}. Using access key instead.") - + # Use session key if available, otherwise use access key if self._session_key: headers["Hydrus-Client-API-Session-Key"] = self._session_key @@ -496,6 +487,7 @@ class HydrusNetwork: file_service_name: str | None = None, return_hashes: bool = False, return_file_ids: bool = True, + return_file_count: bool = False, include_current_tags: bool | None = None, include_pending_tags: bool | None = None, file_sort_type: int | None = None, @@ -511,6 +503,7 @@ class HydrusNetwork: ("file_service_name", file_service_name, lambda v: v), ("return_hashes", return_hashes, lambda v: "true" if v else None), ("return_file_ids", return_file_ids, lambda v: "true" if v else None), + ("return_file_count", return_file_count, lambda v: "true" if v else None), ( "include_current_tags", include_current_tags, @@ -1222,15 +1215,17 @@ def is_hydrus_available(config: dict[str, Any]) -> bool: def get_client(config: dict[str, Any]) -> HydrusNetwork: - """Create and return a Hydrus client with session key authentication. - - Reuses cached client instance to preserve session keys across requests. + """Create and return a Hydrus client. + + Uses access-key authentication by default (no session key acquisition). + A session key may still be acquired explicitly by calling + `HydrusNetwork.ensure_session_key()`. Args: config: Configuration dict with Hydrus settings Returns: - HydrusClient instance (with active session key) + HydrusClient instance Raises: RuntimeError: If Hydrus is not configured or unavailable @@ -1259,34 +1254,11 @@ def get_client(config: dict[str, Any]) -> HydrusNetwork: # Check if we have a cached client if cache_key in _hydrus_client_cache: - cached_client = _hydrus_client_cache[cache_key] - # If cached client has a session key, reuse it (don't re-acquire) - if hasattr(cached_client, '_session_key') and cached_client._session_key: - # debug(f"Reusing cached session key for {hydrus_url}") - return cached_client - # If no session key in cache, try to get one - try: - cached_client.ensure_session_key() - return cached_client - except Exception as e: - # If verification fails, remove from cache and create new one - debug(f"Cached client invalid, creating new: {e}") - del _hydrus_client_cache[cache_key] + return _hydrus_client_cache[cache_key] # Create new client client = HydrusNetwork(hydrus_url, access_key, timeout) - # Acquire session key for secure authentication - try: - client.ensure_session_key() - except HydrusConnectionError: - # This should not happen since we checked availability above - debug(f"Hydrus service unavailable during client creation") - raise RuntimeError("Hydrus is unavailable") from None - except Exception as e: - # Log other exceptions but don't fail - client can still work with access_key - debug(f"Warning: Could not acquire session key: {e}") - # Cache the client _hydrus_client_cache[cache_key] = client diff --git a/API/alldebrid.py b/API/alldebrid.py index 37e2712..0f4e202 100644 --- a/API/alldebrid.py +++ b/API/alldebrid.py @@ -12,8 +12,7 @@ import sys from SYS.logger import log, debug import time import logging -from pathlib import Path -from typing import Any, Dict, Optional, Set, List, Sequence +from typing import Any, Dict, Optional, Set, List, Sequence, Tuple from urllib.parse import urlencode, urlparse from .HTTP import HTTPClient @@ -31,6 +30,24 @@ _CACHE_TIMESTAMP: float = 0 _CACHE_DURATION: float = 3600 # 1 hour +# Cache for init-time connectivity checks (api_key fingerprint -> (ok, reason)) +_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {} + + +def _ping_alldebrid(base_url: str) -> Tuple[bool, Optional[str]]: + """Ping the AllDebrid API base URL (no API key required).""" + try: + url = str(base_url or "").rstrip("/") + "/ping" + with HTTPClient(timeout=10.0, headers={'User-Agent': 'downlow/1.0'}) as client: + response = client.get(url) + data = json.loads(response.content.decode('utf-8')) + if data.get('status') == 'success' and data.get('data', {}).get('ping') == 'pong': + return True, None + return False, "Invalid API response" + except Exception as exc: + return False, str(exc) + + class AllDebridClient: """Client for AllDebrid API.""" @@ -50,6 +67,18 @@ class AllDebridClient: if not self.api_key: raise AllDebridError("AllDebrid API key is empty") self.base_url = self.BASE_url[0] # Start with v4 + + # Init-time availability validation (cached per process) + fingerprint = f"base:{self.base_url}" # /ping does not require the api key + cached = _INIT_CHECK_CACHE.get(fingerprint) + if cached is None: + ok, reason = _ping_alldebrid(self.base_url) + _INIT_CHECK_CACHE[fingerprint] = (ok, reason) + else: + ok, reason = cached + + if not ok: + raise AllDebridError(reason or "AllDebrid unavailable") def _request(self, endpoint: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]: """Make a request to AllDebrid API. diff --git a/API/folder.py b/API/folder.py index 8c3fd4c..9fbc561 100644 --- a/API/folder.py +++ b/API/folder.py @@ -1842,8 +1842,21 @@ class LocalLibraryInitializer: self.db.connection.commit() self._import_sidecars_batch() self.db.connection.commit() + + # Ensure files without sidecars are still imported + renamed to hash. + self._hash_and_rename_non_sidecar_media_files() + self.db.connection.commit() + self._cleanup_orphaned_sidecars() self.db.connection.commit() + + try: + cursor = self.db.connection.cursor() + cursor.execute("SELECT COUNT(*) FROM files") + row = cursor.fetchone() + self.stats['files_total_db'] = int(row[0]) if row and row[0] is not None else 0 + except Exception: + self.stats['files_total_db'] = 0 logger.info(f"Library scan complete. Stats: {self.stats}") return self.stats @@ -1853,12 +1866,140 @@ class LocalLibraryInitializer: raise finally: self.db.close() + + def _hash_and_rename_non_sidecar_media_files(self) -> None: + """Ensure media files are hash-named even when they have no sidecars. + + This keeps the library stable across restarts: + - New files get hashed + renamed to + - DB file_path is updated by hash so the same file isn't re-counted as "new". + """ + try: + renamed = 0 + skipped_existing_target = 0 + duplicates_quarantined = 0 + + for file_path in self._find_media_files(): + try: + if not file_path.is_file(): + continue + + stem = file_path.stem.lower() + is_hash_named = len(stem) == 64 and all(ch in "0123456789abcdef" for ch in stem) + if is_hash_named: + continue + + # If any sidecars exist for this file, let the sidecar importer handle it. + if ( + file_path.with_name(file_path.name + ".tag").exists() + or file_path.with_name(file_path.name + ".metadata").exists() + or file_path.with_name(file_path.name + ".notes").exists() + ): + continue + + file_hash = sha256_file(file_path) + target_path = file_path.with_name(f"{file_hash}{file_path.suffix}") + + # Ensure the DB entry exists with a title tag derived from the original filename. + # This intentionally happens BEFORE rename. + self.db.get_or_create_file_entry(file_path, file_hash) + + if target_path == file_path: + continue + + if target_path.exists(): + skipped_existing_target += 1 + # The canonical file already exists as a hash-named file. Keep the DB pointing + # at the canonical hash-named path and quarantine this duplicate so it doesn't + # get counted as "new" again on future restarts. + try: + cursor = self.db.connection.cursor() + cursor.execute( + "UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?", + (str(target_path.resolve()), file_hash), + ) + except Exception as exc: + logger.debug(f"Failed to reset DB path to canonical file for {file_hash}: {exc}") + + try: + dup_dir = self.library_root / ".duplicates" + dup_dir.mkdir(parents=True, exist_ok=True) + + dest = dup_dir / file_path.name + if dest.exists(): + ts = int(datetime.now().timestamp()) + dest = dup_dir / f"{file_path.stem}__dup__{ts}{file_path.suffix}" + + logger.warning( + f"Duplicate content (hash={file_hash}) detected; moving {file_path} -> {dest}" + ) + file_path.rename(dest) + duplicates_quarantined += 1 + except Exception as exc: + logger.warning( + f"Duplicate content (hash={file_hash}) detected but could not quarantine {file_path}: {exc}" + ) + continue + + try: + file_path.rename(target_path) + except Exception as exc: + logger.warning(f"Failed to rename {file_path} -> {target_path}: {exc}") + self.stats['errors'] += 1 + continue + + # Update DB path by hash (more robust than matching the old path). + try: + cursor = self.db.connection.cursor() + cursor.execute( + "UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?", + (str(target_path.resolve()), file_hash), + ) + except Exception: + pass + + # Ensure basic metadata exists. + try: + stat_result = target_path.stat() + self.db.save_metadata( + target_path, + { + "hash": file_hash, + "ext": target_path.suffix, + "size": stat_result.st_size, + }, + ) + except Exception: + pass + + renamed += 1 + except Exception as exc: + logger.warning(f"Error hashing/renaming file {file_path}: {exc}") + self.stats['errors'] += 1 + + if renamed: + self.stats['files_hashed_renamed'] = int(self.stats.get('files_hashed_renamed', 0) or 0) + renamed + if skipped_existing_target: + self.stats['files_hashed_skipped_target_exists'] = int( + self.stats.get('files_hashed_skipped_target_exists', 0) or 0 + ) + skipped_existing_target + if duplicates_quarantined: + self.stats['duplicates_quarantined'] = int(self.stats.get('duplicates_quarantined', 0) or 0) + duplicates_quarantined + except Exception as exc: + logger.error(f"Error hashing/renaming non-sidecar media files: {exc}", exc_info=True) + self.stats['errors'] += 1 def _find_media_files(self) -> List[Path]: """Find all media files in the library folder.""" media_files = [] try: for file_path in self.library_root.rglob("*"): + # Don't repeatedly re-scan quarantined duplicates. + try: + if ".duplicates" in file_path.parts: + continue + except Exception: + pass if file_path.is_file() and file_path.suffix.lower() in MEDIA_EXTENSIONS: media_files.append(file_path) except Exception as e: @@ -1882,7 +2023,7 @@ class LocalLibraryInitializer: logger.error(f"Error getting database files: {e}", exc_info=True) return {} - def _process_file(self, file_path: Path, db_files: Dict[str, int]) -> None: + def _process_file(self, file_path: Path, db_files: Dict[str, str]) -> None: """Process a single media file.""" try: normalized = str(file_path.resolve()).lower() @@ -1890,8 +2031,23 @@ class LocalLibraryInitializer: if normalized in db_files: self.stats['files_existing'] += 1 else: - self.db.get_or_create_file_entry(file_path) - self.stats['files_new'] += 1 + # Path not known. If this file's hash is already in DB, it's duplicate content and + # should not be counted as "new". + file_hash = sha256_file(file_path) + try: + cursor = self.db.connection.cursor() + cursor.execute("SELECT 1 FROM files WHERE hash = ?", (file_hash,)) + exists_by_hash = cursor.fetchone() is not None + except Exception: + exists_by_hash = False + + if exists_by_hash: + self.stats['files_existing'] += 1 + self.stats['duplicates_found'] = int(self.stats.get('duplicates_found', 0) or 0) + 1 + logger.info(f"Duplicate content detected during scan (hash={file_hash}): {file_path}") + else: + self.db.get_or_create_file_entry(file_path, file_hash) + self.stats['files_new'] += 1 self.stats['files_scanned'] += 1 except Exception as e: diff --git a/CLI.py b/CLI.py index 4e3ccd5..fce4e7e 100644 --- a/CLI.py +++ b/CLI.py @@ -779,14 +779,14 @@ def _create_cmdlet_cli(): if startup_table: startup_table.set_no_choice(True).set_preserve_order(True) - def _add_startup_check(name: str, status: str, detail: str = "") -> None: + def _add_startup_check(status: str, name: str, store_or_provider: str, detail: str = "") -> None: if startup_table is None: return row = startup_table.add_row() - row.add_column("Check", name) row.add_column("Status", status) - if detail: - row.add_column("Detail", detail) + row.add_column("Name", name) + row.add_column("Store/Provi", store_or_provider) + row.add_column("Detail", detail or "") def _has_store_subtype(cfg: dict, subtype: str) -> bool: store_cfg = cfg.get("store") @@ -831,67 +831,150 @@ def _create_cmdlet_cli(): # Run startup checks and render table try: - from hydrus_health_check import ( - initialize_mpv_health_check, - initialize_matrix_health_check, - initialize_hydrus_health_check, - initialize_local_library_scan, - initialize_cookies_check, - initialize_debrid_health_check, - ) + from hydrus_health_check import initialize_cookies_check - def _run_check(name: str, fn: Callable[[], Tuple[bool, Optional[str]]], skip_reason: Optional[str] = None) -> None: - if skip_reason: - _add_startup_check(name, "SKIPPED", skip_reason) - return + # MPV availability is validated by MPV.MPV.__init__. + try: + from MPV.mpv_ipc import MPV + + MPV() try: - ok, detail = fn() - status = "ENABLED" if name in {"MPV", "Hydrus", "Matrix", "Debrid"} else ("FOUND" if name == "Cookies" else "SCANNED") - if name == "Matrix": - status = "ENABLED" if ok else "DISABLED" - elif name == "Folder Stores": - status = "SCANNED" if ok else "SKIPPED" - elif name == "Cookies": - status = "FOUND" if ok else "MISSING" - elif name in {"MPV", "Hydrus", "Debrid"}: - status = "ENABLED" if ok else "DISABLED" - _add_startup_check(name, status, detail or "") - except Exception as exc: # Best-effort: never block startup - _add_startup_check(name, "ERROR", str(exc)) + import shutil - _run_check("MPV", lambda: initialize_mpv_health_check(emit_debug=False)) + mpv_path = shutil.which("mpv") + except Exception: + mpv_path = None + + _add_startup_check("ENABLED", "MPV", "N/A", mpv_path or "Available") + except Exception as exc: + _add_startup_check("DISABLED", "MPV", "N/A", str(exc)) + + store_registry = None if config: + # Instantiate store registry once; store __init__ performs its own validation. + try: + from Store import Store as StoreRegistry + + store_registry = StoreRegistry(config=config, suppress_debug=True) + except Exception: + store_registry = None + # Only show checks that are configured in config.conf if _has_store_subtype(config, "hydrusnetwork"): - _run_check("Hydrus", lambda: initialize_hydrus_health_check(config, emit_debug=False)) + # HydrusNetwork self-validates in its __init__. We derive instance status from + # store instantiation rather than a separate Hydrus-specific health check. + store_cfg = config.get("store") + hydrus_cfg = store_cfg.get("hydrusnetwork", {}) if isinstance(store_cfg, dict) else {} + if isinstance(hydrus_cfg, dict): + for instance_name, instance_cfg in hydrus_cfg.items(): + if not isinstance(instance_cfg, dict): + continue + name_key = str(instance_cfg.get("NAME") or instance_name) + url_val = str(instance_cfg.get("URL") or "").strip() - # Hydrus instances - add individual rows for each configured instance - from hydrus_health_check import _SERVICE_STATE - for instance_name, instance_info in _SERVICE_STATE.get("hydrusnetwork_stores", {}).items(): - status = "ENABLED" if instance_info.get("ok") else "DISABLED" - _add_startup_check(f" {instance_name}", status, f"{instance_info.get('url')} - {instance_info.get('detail')}") + ok = bool(store_registry and store_registry.is_available(name_key)) + status = "ENABLED" if ok else "DISABLED" + if ok: + total = None + try: + if store_registry: + backend = store_registry[name_key] + total = getattr(backend, "total_count", None) + except Exception: + total = None + + detail = (url_val + (" - " if url_val else "")) + "Connected" + if isinstance(total, int) and total >= 0: + detail += f" (Total: {total})" + else: + err = None + if store_registry: + err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) + detail = (url_val + (" - " if url_val else "")) + (err or "Unavailable") + _add_startup_check(status, name_key, "hydrusnetwork", detail) if _has_provider(config, "matrix"): - _run_check("Matrix", lambda: initialize_matrix_health_check(config, emit_debug=False)) + # Matrix availability is validated by Provider.matrix.Matrix.__init__. + try: + from Provider.matrix import Matrix + + provider = Matrix(config) + matrix_conf = config.get("provider", {}).get("matrix", {}) if isinstance(config, dict) else {} + homeserver = str(matrix_conf.get("homeserver") or "").strip() + room_id = str(matrix_conf.get("room_id") or "").strip() + if homeserver and not homeserver.startswith("http"): + homeserver = f"https://{homeserver}" + target = homeserver.rstrip("/") + if room_id: + target = (target + (" " if target else "")) + f"room:{room_id}" + + if provider.validate(): + _add_startup_check("ENABLED", "Matrix", "matrix", target or "Connected") + else: + missing: list[str] = [] + if not homeserver: + missing.append("homeserver") + if not room_id: + missing.append("room_id") + if not (matrix_conf.get("access_token") or matrix_conf.get("password")): + missing.append("access_token/password") + detail = "Not configured" + (f" ({', '.join(missing)})" if missing else "") + _add_startup_check("DISABLED", "Matrix", "matrix", detail) + except Exception as exc: + _add_startup_check("DISABLED", "Matrix", "matrix", str(exc)) if _has_store_subtype(config, "folder"): - # Folder stores - add individual rows for each configured store - ok, detail = initialize_local_library_scan(config, emit_debug=False) - if ok or detail != "No folder stores configured": - from hydrus_health_check import _SERVICE_STATE - for store_name, store_info in _SERVICE_STATE.get("folder_stores", {}).items(): - status = "SCANNED" if store_info.get("ok") else "ERROR" - _add_startup_check(f" {store_name}", status, f"{store_info.get('path')} - {store_info.get('detail')}") - if not _SERVICE_STATE.get("folder_stores"): - _add_startup_check("Folder Stores", "SCANNED", detail) + # Folder local scan/index is performed by Store.Folder.__init__. + store_cfg = config.get("store") + folder_cfg = store_cfg.get("folder", {}) if isinstance(store_cfg, dict) else {} + if isinstance(folder_cfg, dict) and folder_cfg: + for instance_name, instance_cfg in folder_cfg.items(): + if not isinstance(instance_cfg, dict): + continue + name_key = str(instance_cfg.get("NAME") or instance_name) + path_val = str(instance_cfg.get("PATH") or instance_cfg.get("path") or "").strip() + + ok = bool(store_registry and store_registry.is_available(name_key)) + if ok and store_registry: + backend = store_registry[name_key] + scan_ok = bool(getattr(backend, "scan_ok", True)) + scan_detail = str(getattr(backend, "scan_detail", "") or "") + status = "SCANNED" if scan_ok else "ERROR" + detail = (path_val + (" - " if path_val else "")) + (scan_detail or "Up to date") + _add_startup_check(status, name_key, "folder", detail) + else: + err = None + if store_registry: + err = store_registry.get_backend_error(instance_name) or store_registry.get_backend_error(name_key) + detail = (path_val + (" - " if path_val else "")) + (err or "Unavailable") + _add_startup_check("ERROR", name_key, "folder", detail) else: - _add_startup_check("Folder Stores", "SKIPPED", detail) + _add_startup_check("SKIPPED", "Folder", "folder", "No folder stores configured") if _has_store_subtype(config, "debrid"): - _run_check("Debrid", lambda: initialize_debrid_health_check(config, emit_debug=False)) + # Debrid availability is validated by API.alldebrid.AllDebridClient.__init__. + try: + from config import get_debrid_api_key - _run_check("Cookies", lambda: initialize_cookies_check(config, emit_debug=False)) + api_key = get_debrid_api_key(config) + if not api_key: + _add_startup_check("DISABLED", "Debrid", "debrid", "Not configured") + else: + from API.alldebrid import AllDebridClient + + client = AllDebridClient(api_key) + base_url = str(getattr(client, "base_url", "") or "").strip() + _add_startup_check("ENABLED", "Debrid", "debrid", base_url or "Connected") + except Exception as exc: + _add_startup_check("DISABLED", "Debrid", "debrid", str(exc)) + + # Cookies are used by yt-dlp; keep this centralized utility. + try: + ok, detail = initialize_cookies_check(config, emit_debug=False) + _add_startup_check("FOUND" if ok else "MISSING", "Cookies", "N/A", detail or "Not found") + except Exception as exc: + _add_startup_check("ERROR", "Cookies", "N/A", str(exc)) if startup_table is not None and startup_table.rows: print() @@ -1156,14 +1239,14 @@ def _execute_pipeline(tokens: list): and the actual items being selected to help diagnose reordering issues. """ try: - print(f"[debug] {label}: sel={selection_indices} rows={len(table_obj.rows) if table_obj and hasattr(table_obj, 'rows') else 'n/a'} items={len(items_list) if items_list is not None else 'n/a'}") + debug(f"[debug] {label}: sel={selection_indices} rows={len(table_obj.rows) if table_obj and hasattr(table_obj, 'rows') else 'n/a'} items={len(items_list) if items_list is not None else 'n/a'}") if table_obj and hasattr(table_obj, 'rows') and items_list: # Show correspondence: displayed row # -> source_index -> item hash/title for i in selection_indices: if 0 <= i < len(table_obj.rows): row = table_obj.rows[i] src_idx = getattr(row, 'source_index', None) - print(f"[debug] @{i+1} -> row_index={i}, source_index={src_idx}", end='') + debug(f"[debug] @{i+1} -> row_index={i}, source_index={src_idx}", end='') if src_idx is not None and 0 <= src_idx < len(items_list): item = items_list[src_idx] # Try to show hash/title for verification @@ -1181,9 +1264,9 @@ def _execute_pipeline(tokens: list): else: print(" -> [source_index out of range]") if resolved_list is not None: - print(f"[debug] resolved_len={len(resolved_list)}") + debug(f"[debug] resolved_len={len(resolved_list)}") except Exception as e: - print(f"[debug] error in _debug_selection: {e}") + debug(f"[debug] error in _debug_selection: {e}") # Split tokens by pipe operator stages = [] diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index ed7fa73..8c0b79f 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -14,8 +14,9 @@ import socket import subprocess import sys import time as _time +import shutil from pathlib import Path -from typing import Any, Dict, Optional, List, BinaryIO, cast +from typing import Any, Dict, Optional, List, BinaryIO, Tuple, cast from SYS.logger import debug @@ -29,6 +30,44 @@ _LYRIC_PROCESS: Optional[subprocess.Popen] = None _LYRIC_LOG_FH: Optional[Any] = None +_MPV_AVAILABILITY_CACHE: Optional[Tuple[bool, Optional[str]]] = None + + +def _check_mpv_availability() -> Tuple[bool, Optional[str]]: + """Return (available, reason) for the mpv executable. + + This checks that: + - `mpv` is present in PATH + - `mpv --version` can run successfully + + Result is cached per-process to avoid repeated subprocess calls. + """ + global _MPV_AVAILABILITY_CACHE + if _MPV_AVAILABILITY_CACHE is not None: + return _MPV_AVAILABILITY_CACHE + + mpv_path = shutil.which("mpv") + if not mpv_path: + _MPV_AVAILABILITY_CACHE = (False, "Executable 'mpv' not found in PATH") + return _MPV_AVAILABILITY_CACHE + + try: + result = subprocess.run( + [mpv_path, "--version"], + capture_output=True, + text=True, + timeout=2, + ) + if result.returncode == 0: + _MPV_AVAILABILITY_CACHE = (True, None) + return _MPV_AVAILABILITY_CACHE + _MPV_AVAILABILITY_CACHE = (False, f"MPV returned non-zero exit code: {result.returncode}") + return _MPV_AVAILABILITY_CACHE + except Exception as exc: + _MPV_AVAILABILITY_CACHE = (False, f"Error running MPV: {exc}") + return _MPV_AVAILABILITY_CACHE + + def _windows_list_lyric_helper_pids(ipc_path: str) -> List[int]: """Return PIDs of `python -m MPV.lyric --ipc ` helpers (Windows only).""" if platform.system() != "Windows": @@ -130,6 +169,11 @@ class MPV: lua_script_path: Optional[str | Path] = None, timeout: float = 5.0, ) -> None: + + ok, reason = _check_mpv_availability() + if not ok: + raise MPVIPCError(reason or "MPV unavailable") + self.timeout = timeout self.ipc_path = ipc_path or get_ipc_pipe_path() diff --git a/Provider/matrix.py b/Provider/matrix.py index 6e5b369..7499b12 100644 --- a/Provider/matrix.py +++ b/Provider/matrix.py @@ -2,19 +2,89 @@ from __future__ import annotations import mimetypes from pathlib import Path -from typing import Any +from typing import Any, Dict, Optional, Tuple import requests from ProviderCore.base import FileProvider +_MATRIX_INIT_CHECK_CACHE: Dict[str, Tuple[bool, Optional[str]]] = {} + + +def _normalize_homeserver(value: str) -> str: + text = str(value or "").strip() + if not text: + return "" + if not text.startswith("http"): + text = f"https://{text}" + return text.rstrip("/") + + +def _matrix_health_check(*, homeserver: str, access_token: Optional[str]) -> Tuple[bool, Optional[str]]: + """Lightweight Matrix reachability/auth validation. + + - Always checks `/versions` (no auth). + - If `access_token` is present, also checks `/whoami`. + """ + try: + base = _normalize_homeserver(homeserver) + if not base: + return False, "Matrix homeserver missing" + + resp = requests.get(f"{base}/_matrix/client/versions", timeout=5) + if resp.status_code != 200: + return False, f"Homeserver returned {resp.status_code}" + + if access_token: + headers = {"Authorization": f"Bearer {access_token}"} + resp = requests.get(f"{base}/_matrix/client/v3/account/whoami", headers=headers, timeout=5) + if resp.status_code != 200: + return False, f"Authentication failed: {resp.status_code}" + + return True, None + except Exception as exc: + return False, str(exc) + + class Matrix(FileProvider): """File provider for Matrix (Element) chat rooms.""" + def __init__(self, config: Optional[Dict[str, Any]] = None): + super().__init__(config) + self._init_ok: Optional[bool] = None + self._init_reason: Optional[str] = None + + matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {} + homeserver = matrix_conf.get("homeserver") + room_id = matrix_conf.get("room_id") + access_token = matrix_conf.get("access_token") + password = matrix_conf.get("password") + + # Not configured: keep instance but mark invalid via validate(). + if not (homeserver and room_id and (access_token or password)): + self._init_ok = None + self._init_reason = None + return + + cache_key = f"{_normalize_homeserver(str(homeserver))}|room:{room_id}|has_token:{bool(access_token)}" + cached = _MATRIX_INIT_CHECK_CACHE.get(cache_key) + if cached is None: + ok, reason = _matrix_health_check(homeserver=str(homeserver), access_token=str(access_token) if access_token else None) + _MATRIX_INIT_CHECK_CACHE[cache_key] = (ok, reason) + else: + ok, reason = cached + + self._init_ok = ok + self._init_reason = reason + if not ok: + raise Exception(reason or "Matrix unavailable") + def validate(self) -> bool: if not self.config: return False + if self._init_ok is False: + return False matrix_conf = self.config.get("provider", {}).get("matrix", {}) return bool( matrix_conf.get("homeserver") diff --git a/SYS/progress.py b/SYS/progress.py index 8ce3c32..121ddc1 100644 --- a/SYS/progress.py +++ b/SYS/progress.py @@ -3,7 +3,7 @@ import sys -from SYS.logger import log, debug +from SYS.logger import log def format_progress_bar(current: int, total: int, width: int = 40, label: str = "") -> str: @@ -69,7 +69,7 @@ def format_download_status(filename: str, current: int, total: int, speed: float def print_progress(filename: str, current: int, total: int, speed: float = 0, end: str = "\r") -> None: """Print download progress to stderr (doesn't interfere with piped output).""" status = format_download_status(filename, current, total, speed) - debug(status, end=end, flush=True) + print(status, file=sys.stderr, end=end, flush=True) def print_final_progress(filename: str, total: int, elapsed: float) -> None: @@ -86,7 +86,7 @@ def print_final_progress(filename: str, total: int, elapsed: float) -> None: hours = elapsed / 3600 time_str = f"{hours:.2f}h" - debug(f"{bar} ({size_str}) - {time_str}") + print(f"{bar} ({size_str}) - {time_str}", file=sys.stderr, flush=True) if __name__ == "__main__": diff --git a/Store/Folder.py b/Store/Folder.py index 9d38d91..57df4b4 100644 --- a/Store/Folder.py +++ b/Store/Folder.py @@ -34,6 +34,8 @@ class Folder(Store): """""" # Track which locations have already been migrated to avoid repeated migrations _migrated_locations = set() + # Cache scan results to avoid repeated full scans across repeated instantiations + _scan_cache: Dict[str, Tuple[bool, str, Dict[str, int]]] = {} def __new__(cls, *args: Any, **kwargs: Any) -> "Folder": return super().__new__(cls) @@ -55,10 +57,16 @@ class Folder(Store): self._location = location self._name = name + + # Scan status (set during init) + self.scan_ok: bool = True + self.scan_detail: str = "" + self.scan_stats: Dict[str, int] = {} if self._location: try: from API.folder import API_folder_store + from API.folder import LocalLibraryInitializer from pathlib import Path location_path = Path(self._location).expanduser() @@ -69,6 +77,29 @@ class Folder(Store): # Call migration and discovery at startup Folder.migrate_location(self._location) + + # Local library scan/index (one-time per location per process) + location_key = str(location_path) + cached = Folder._scan_cache.get(location_key) + if cached is None: + try: + initializer = LocalLibraryInitializer(location_path) + stats = initializer.scan_and_index() or {} + files_new = int(stats.get('files_new', 0) or 0) + sidecars = int(stats.get('sidecars_imported', 0) or 0) + total_db = int(stats.get('files_total_db', 0) or 0) + if files_new > 0 or sidecars > 0: + detail = f"New: {files_new}, Sidecars: {sidecars}" + (f" (Total: {total_db})" if total_db else "") + else: + detail = ("Up to date" + (f" (Total: {total_db})" if total_db else "")) + Folder._scan_cache[location_key] = (True, detail, dict(stats)) + except Exception as exc: + Folder._scan_cache[location_key] = (False, f"Scan failed: {exc}", {}) + + ok, detail, stats = Folder._scan_cache.get(location_key, (True, "", {})) + self.scan_ok = bool(ok) + self.scan_detail = str(detail or "") + self.scan_stats = dict(stats or {}) except Exception as exc: debug(f"Failed to initialize database for '{name}': {exc}") @@ -87,12 +118,11 @@ class Folder(Store): return cls._migrated_locations.add(location_str) - - # Create a temporary instance just to call the migration - temp_instance = cls(location=location) - temp_instance._migrate_to_hash_storage(location_path) - def _migrate_to_hash_storage(self, location_path: Path) -> None: + cls._migrate_to_hash_storage(location_path) + + @classmethod + def _migrate_to_hash_storage(cls, location_path: Path) -> None: """Migrate existing files from filename-based to hash-based storage. Checks for sidecars (.metadata, .tag) and imports them before renaming. @@ -158,6 +188,15 @@ class Folder(Store): if hash_path != file_path and not hash_path.exists(): debug(f"Migrating: {file_path.name} -> {hash_filename}", file=sys.stderr) file_path.rename(hash_path) + + # Ensure DB points to the renamed path (update by hash). + try: + cursor.execute( + "UPDATE files SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE hash = ?", + (str(hash_path.resolve()), file_hash), + ) + except Exception: + pass # Create or update database entry db.get_or_create_file_entry(hash_path) diff --git a/Store/HydrusNetwork.py b/Store/HydrusNetwork.py index 115770c..b9095cc 100644 --- a/Store/HydrusNetwork.py +++ b/Store/HydrusNetwork.py @@ -5,17 +5,22 @@ import sys from pathlib import Path from typing import Any, Dict, List, Optional, Tuple +import httpx + from SYS.logger import debug, log from SYS.utils_constant import mime_maps from Store._base import Store +_HYDRUS_INIT_CHECK_CACHE: dict[tuple[str, str], tuple[bool, Optional[str]]] = {} + + class HydrusNetwork(Store): """File storage backend for Hydrus client. Each instance represents a specific Hydrus client connection. - Maintains its own HydrusClient with session key. + Maintains its own HydrusClient. """ def __new__(cls, *args: Any, **kwargs: Any) -> "HydrusNetwork": @@ -64,22 +69,67 @@ class HydrusNetwork(Store): self.NAME = instance_name self.API = api_key - self.URL = url - # Create persistent client with session key for this instance - self._client = HydrusClient(url=url, access_key=api_key) + self.URL = url.rstrip("/") - # Self health-check: acquire a session key immediately so broken configs - # fail-fast and the registry can skip registering this backend. - try: - if self._client is not None: - self._client.ensure_session_key() - except Exception as exc: - # Best-effort cleanup so partially constructed objects don't linger. + # Total count (best-effort, used for startup diagnostics) + self.total_count: Optional[int] = None + + # Self health-check: validate the URL is reachable and the access key is accepted. + # This MUST NOT attempt to acquire a session key. + cache_key = (self.URL, self.API) + cached = _HYDRUS_INIT_CHECK_CACHE.get(cache_key) + if cached is not None: + ok, err = cached + if not ok: + raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {err or 'Unavailable'}") + else: + api_version_url = f"{self.URL}/api_version" + verify_key_url = f"{self.URL}/verify_access_key" try: - self._client = None - except Exception: - pass - raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {exc}") from exc + with httpx.Client(timeout=5.0, verify=False, follow_redirects=True) as client: + version_resp = client.get(api_version_url) + version_resp.raise_for_status() + version_payload = version_resp.json() + if not isinstance(version_payload, dict): + raise RuntimeError("Hydrus /api_version returned an unexpected response") + + verify_resp = client.get( + verify_key_url, + headers={"Hydrus-Client-API-Access-Key": self.API}, + ) + verify_resp.raise_for_status() + verify_payload = verify_resp.json() + if not isinstance(verify_payload, dict): + raise RuntimeError("Hydrus /verify_access_key returned an unexpected response") + + _HYDRUS_INIT_CHECK_CACHE[cache_key] = (True, None) + except Exception as exc: + err = str(exc) + _HYDRUS_INIT_CHECK_CACHE[cache_key] = (False, err) + raise RuntimeError(f"Hydrus '{self.NAME}' unavailable: {err}") from exc + + # Create a persistent client for this instance (auth via access key by default). + self._client = HydrusClient(url=self.URL, access_key=self.API) + + # Best-effort total count (fast on Hydrus side; does not fetch IDs/hashes). + try: + payload = self._client.search_files( + tags=["system:everything"], + return_hashes=False, + return_file_ids=False, + return_file_count=True, + ) + count_val = None + if isinstance(payload, dict): + count_val = payload.get("file_count") + if count_val is None: + count_val = payload.get("file_count_inclusive") + if count_val is None: + count_val = payload.get("num_files") + if isinstance(count_val, int): + self.total_count = count_val + except Exception as exc: + debug(f"Hydrus total count unavailable for '{self.NAME}': {exc}", file=sys.stderr) def name(self) -> str: return self.NAME diff --git a/Store/registry.py b/Store/registry.py index 3b2067c..900b978 100644 --- a/Store/registry.py +++ b/Store/registry.py @@ -22,6 +22,11 @@ from SYS.logger import debug from Store._base import Store as BaseStore +# Backends that failed to initialize earlier in the current process. +# Keyed by (store_type, instance_key) where instance_key is the name used under config.store... +_FAILED_BACKEND_CACHE: Dict[tuple[str, str], str] = {} + + def _normalize_store_type(value: str) -> str: return "".join(ch.lower() for ch in str(value or "") if ch.isalnum()) @@ -111,6 +116,7 @@ class Store: self._config = config or {} self._suppress_debug = suppress_debug self._backends: Dict[str, BaseStore] = {} + self._backend_errors: Dict[str, str] = {} self._load_backends() def _load_backends(self) -> None: @@ -131,6 +137,18 @@ class Store: continue for instance_name, instance_config in instances.items(): + backend_name = str(instance_name) + + # If this backend already failed earlier in this process, skip re-instantiation. + cache_key = (store_type, str(instance_name)) + cached_error = _FAILED_BACKEND_CACHE.get(cache_key) + if cached_error: + self._backend_errors[str(instance_name)] = str(cached_error) + if isinstance(instance_config, dict): + override_name = _get_case_insensitive(dict(instance_config), "NAME") + if override_name: + self._backend_errors[str(override_name)] = str(cached_error) + continue try: kwargs = _build_kwargs(store_cls, str(instance_name), instance_config) @@ -144,11 +162,17 @@ class Store: backend_name = str(kwargs.get("NAME") or instance_name) self._backends[backend_name] = backend except Exception as exc: + err_text = str(exc) + self._backend_errors[str(instance_name)] = err_text + _FAILED_BACKEND_CACHE[cache_key] = err_text if not self._suppress_debug: debug( f"[Store] Failed to register {store_cls.__name__} instance '{instance_name}': {exc}" ) + def get_backend_error(self, backend_name: str) -> Optional[str]: + return self._backend_errors.get(str(backend_name)) + def list_backends(self) -> list[str]: return sorted(self._backends.keys()) diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 358a393..be489f6 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -441,6 +441,35 @@ class Add_File(Cmdlet): ctx.emit(pipe_obj.to_dict()) ctx.set_current_stage_table(None) + @staticmethod + def _emit_storage_result(payload: Dict[str, Any]) -> None: + """Emit a storage-style result payload. + + - Always emits the dict downstream (when in a pipeline). + - If this is the last stage (or not in a pipeline), prints a search-store-like table + and sets an overlay table/items for @N selection. + """ + # Always emit for downstream commands (no-op if not in a pipeline) + ctx.emit(payload) + + stage_ctx = ctx.get_stage_context() + is_last = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False)) + if not is_last: + return + + try: + from result_table import ResultTable + table = ResultTable("Result") + table.add_result(payload) + # Overlay so @1 refers to this add-file result without overwriting search history + ctx.set_last_result_table_overlay(table, [payload], subject=payload) + except Exception: + # If table rendering fails, still keep @ selection items + try: + ctx.set_last_result_items_only([payload]) + except Exception: + pass + @staticmethod def _prepare_metadata( result: Any, @@ -788,7 +817,55 @@ class Add_File(Cmdlet): "url": url, }, ) - Add_File._emit_pipe_object(pipe_obj) + + # Emit a search-store-like payload for consistent tables and natural piping. + # Keep hash/store for downstream commands (get-tag, get-file, etc.). + resolved_hash = file_identifier if len(file_identifier) == 64 else (f_hash or file_identifier or "unknown") + + meta: Dict[str, Any] = {} + try: + meta = backend.get_metadata(resolved_hash) or {} + except Exception: + meta = {} + + # Determine size bytes + size_bytes: Optional[int] = None + for key in ("size_bytes", "size", "filesize", "file_size"): + try: + raw_size = meta.get(key) + if raw_size is not None: + size_bytes = int(raw_size) + break + except Exception: + pass + if size_bytes is None: + try: + size_bytes = int(media_path.stat().st_size) + except Exception: + size_bytes = None + + # Determine title/ext + title_out = ( + meta.get("title") + or title + or pipe_obj.title + or media_path.stem + or media_path.name + ) + ext_out = (meta.get("ext") or media_path.suffix.lstrip(".")) + + payload: Dict[str, Any] = { + "title": title_out, + "ext": str(ext_out or ""), + "size_bytes": size_bytes, + "store": backend_name, + "hash": resolved_hash, + # Preserve extra fields for downstream commands (kept hidden by default table rules) + "path": stored_path, + "tag": list(tags or []), + "url": list(url or []), + } + Add_File._emit_storage_result(payload) Add_File._cleanup_after_success(media_path, delete_source=delete_after) return 0 diff --git a/cmdlet/download_media.py b/cmdlet/download_media.py index 3c0c5f8..de95d33 100644 --- a/cmdlet/download_media.py +++ b/cmdlet/download_media.py @@ -57,6 +57,9 @@ except ImportError: _EXTRACTOR_CACHE: List[Any] | None = None +# Reused progress formatter for yt-dlp callbacks (stderr only). +_YTDLP_PROGRESS_BAR = ProgressBar() + def _ensure_yt_dlp_ready() -> None: if yt_dlp is not None: @@ -248,7 +251,8 @@ def _build_ytdlp_options(opts: DownloadOptions) -> Dict[str, Any]: "fragment_retries": 10, "http_chunk_size": 10_485_760, "restrictfilenames": True, - "progress_hooks": [] if opts.quiet else [_progress_callback], + # Always show a progress indicator; do not tie it to debug logging. + "progress_hooks": [_progress_callback], } if opts.cookies_path and opts.cookies_path.is_file(): @@ -423,17 +427,36 @@ def _progress_callback(status: Dict[str, Any]) -> None: """Simple progress callback using logger.""" event = status.get("status") if event == "downloading": - percent = status.get("_percent_str", "?") - speed = status.get("_speed_str", "?") - eta = status.get("_eta_str", "?") - sys.stdout.write(f"\r[download] {percent} at {speed} ETA {eta} ") - sys.stdout.flush() + # Always print progress to stderr so piped stdout remains clean. + percent = status.get("_percent_str") + downloaded = status.get("downloaded_bytes") + total = status.get("total_bytes") or status.get("total_bytes_estimate") + speed = status.get("_speed_str") + eta = status.get("_eta_str") + + try: + line = _YTDLP_PROGRESS_BAR.format_progress( + percent_str=str(percent) if percent is not None else None, + downloaded=int(downloaded) if downloaded is not None else None, + total=int(total) if total is not None else None, + speed_str=str(speed) if speed is not None else None, + eta_str=str(eta) if eta is not None else None, + ) + except Exception: + pct = str(percent) if percent is not None else "?" + spd = str(speed) if speed is not None else "?" + et = str(eta) if eta is not None else "?" + line = f"[download] {pct} at {spd} ETA {et}" + + sys.stderr.write("\r" + line + " ") + sys.stderr.flush() elif event == "finished": - sys.stdout.write("\r" + " " * 70 + "\r") - sys.stdout.flush() - debug(f"✓ Download finished: {status.get('filename')}") + # Clear the in-place progress line. + sys.stderr.write("\r" + (" " * 140) + "\r") + sys.stderr.write("\n") + sys.stderr.flush() elif event in ("postprocessing", "processing"): - debug(f"Post-processing: {status.get('postprocessor')}") + return def _download_direct_file( @@ -530,17 +553,17 @@ def _download_direct_file( speed_str=speed_str, eta_str=eta_str, ) - if not quiet: - debug(progress_line) + sys.stderr.write("\r" + progress_line + " ") + sys.stderr.flush() last_progress_time[0] = now with HTTPClient(timeout=30.0) as client: client.download(url, str(file_path), progress_callback=progress_callback) - elapsed = time.time() - start_time - avg_speed_str = progress_bar.format_bytes(downloaded_bytes[0] / elapsed if elapsed > 0 else 0) + "/s" - if not quiet: - debug(f"✓ Downloaded in {elapsed:.1f}s at {avg_speed_str}") + # Clear progress line after completion. + sys.stderr.write("\r" + (" " * 140) + "\r") + sys.stderr.write("\n") + sys.stderr.flush() # For direct file downloads, create minimal info dict without filename as title # This prevents creating duplicate title: tags when filename gets auto-generated @@ -1403,9 +1426,16 @@ class Download_Media(Cmdlet): # Emit one PipeObject per downloaded file (playlists/albums return a list) results_to_emit = result_obj if isinstance(result_obj, list) else [result_obj] debug(f"Emitting {len(results_to_emit)} result(s) to pipeline...") + + stage_ctx = pipeline_context.get_stage_context() + emit_enabled = bool(stage_ctx is not None and not getattr(stage_ctx, "is_last_stage", False)) for downloaded in results_to_emit: pipe_obj_dict = self._build_pipe_object(downloaded, url, opts) - pipeline_context.emit(pipe_obj_dict) + + # Only emit when there is a downstream stage. + # This keeps `download-media` from producing a result table when run standalone. + if emit_enabled: + pipeline_context.emit(pipe_obj_dict) # Automatically register url with local library if pipe_obj_dict.get("url"): diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index 4d727dd..06dffef 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -769,7 +769,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: pass except Exception: pass - print(f"MPV log file: {mpv_log_path}") + debug(f"MPV log file: {mpv_log_path}") # If mpv is already running, set log options live via IPC. try: diff --git a/hydrus_health_check.py b/hydrus_health_check.py index 2f3b42e..1c7854b 100644 --- a/hydrus_health_check.py +++ b/hydrus_health_check.py @@ -1,527 +1,20 @@ -"""Hydrus API health check and initialization. +"""Cookies availability helpers. -Provides startup health checks for Hydrus API availability and gracefully -disables Hydrus features if the API is unavailable. +This module is intentionally limited to cookie-file resolution used by yt-dlp. +Other service availability checks live in their owning store/provider objects. """ -import logging import sys -from SYS.logger import log, debug -from typing import Tuple, Optional, Dict, Any from pathlib import Path +from typing import Any, Dict, Optional, Tuple -logger = logging.getLogger(__name__) -# Global state for all service availability checks - consolidated from 12 separate globals -_SERVICE_STATE = { - "hydrus": {"available": None, "reason": None, "complete": False}, - "hydrusnetwork_stores": {}, # Track individual Hydrus instances - "debrid": {"available": None, "reason": None, "complete": False}, - "mpv": {"available": None, "reason": None, "complete": False}, - "matrix": {"available": None, "reason": None, "complete": False}, -} +from SYS.logger import debug # Global state for Cookies availability _COOKIES_FILE_PATH: Optional[str] = None -def check_hydrus_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: - """Check Hydrus availability by instantiating configured HydrusNetwork stores. - - HydrusNetwork now self-checks in its __init__ (it acquires a session key). - If construction fails, the store is treated as unavailable and the error - message becomes the failure reason. - """ - store_config = config.get("store", {}) - hydrusnetwork = store_config.get("hydrusnetwork", {}) if isinstance(store_config, dict) else {} - - if not isinstance(hydrusnetwork, dict) or not hydrusnetwork: - return False, "Not configured" - - from Store.HydrusNetwork import HydrusNetwork - - any_ok = False - last_reason: Optional[str] = None - for instance_name, instance_config in hydrusnetwork.items(): - if not isinstance(instance_config, dict): - continue - url = instance_config.get("URL") - access_key = instance_config.get("API") - if not url or not access_key: - last_reason = "Missing credentials" - continue - try: - HydrusNetwork(NAME=str(instance_name), API=str(access_key), URL=str(url)) - any_ok = True - except Exception as exc: - last_reason = str(exc) - - if any_ok: - return True, None - return False, last_reason or "No reachable Hydrus instances" - - -def initialize_hydrus_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]: - """Initialize Hydrus health check at startup.""" - global _SERVICE_STATE - logger.info("[Startup] Starting Hydrus health check...") - - is_available = False - reason: Optional[str] = None - - # Track individual Hydrus instances (per-instance construction to capture reasons) - _SERVICE_STATE["hydrusnetwork_stores"] = {} - try: - store_config = config.get("store", {}) - hydrusnetwork = store_config.get("hydrusnetwork", {}) if isinstance(store_config, dict) else {} - if isinstance(hydrusnetwork, dict): - from Store.HydrusNetwork import HydrusNetwork - first_error: Optional[str] = None - for instance_name, instance_config in hydrusnetwork.items(): - if not isinstance(instance_config, dict): - continue - url = instance_config.get("URL") - access_key = instance_config.get("API") - - if not url or not access_key: - _SERVICE_STATE["hydrusnetwork_stores"][instance_name] = { - "ok": False, - "url": url or "Not configured", - "detail": "Missing credentials", - } - continue - - try: - HydrusNetwork(NAME=str(instance_name), API=str(access_key), URL=str(url)) - is_available = True - _SERVICE_STATE["hydrusnetwork_stores"][instance_name] = { - "ok": True, - "url": str(url), - "detail": "Connected", - } - except Exception as exc: - if first_error is None: - first_error = str(exc) - _SERVICE_STATE["hydrusnetwork_stores"][instance_name] = { - "ok": False, - "url": str(url), - "detail": str(exc), - } - - if not is_available: - reason = first_error or "No reachable Hydrus instances" - except Exception as e: - logger.debug(f"Could not enumerate Hydrus instances: {e}") - is_available = False - reason = str(e) - - _SERVICE_STATE["hydrus"]["available"] = is_available - _SERVICE_STATE["hydrus"]["reason"] = reason - _SERVICE_STATE["hydrus"]["complete"] = True - - if emit_debug: - status = 'ENABLED' if is_available else f'DISABLED - {reason or "Connection failed"}' - debug(f"Hydrus: {status}", file=sys.stderr) - return is_available, reason - - -def check_debrid_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: - """Check if Debrid API is available.""" - try: - try: - from config import get_debrid_api_key - # Require at least one configured key to consider Debrid configured. - if not get_debrid_api_key(config): - return False, "Not configured" - except Exception: - return False, "Not configured" - - from API.HTTP import HTTPClient - logger.info("[Debrid Health Check] Pinging Debrid API...") - with HTTPClient(timeout=10.0, verify_ssl=True) as client: - response = client.get('https://api.alldebrid.com/v4/ping') - result = response.json() - if result.get('status') == 'success' and result.get('data', {}).get('ping') == 'pong': - logger.info("[Debrid Health Check] Debrid API is AVAILABLE") - return True, None - return False, "Invalid API response" - except Exception as e: - logger.warning(f"[Debrid Health Check] Debrid API error: {e}") - return False, str(e) - - -def initialize_debrid_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]: - """Initialize Debrid health check at startup.""" - global _SERVICE_STATE - logger.info("[Startup] Starting Debrid health check...") - is_available, reason = check_debrid_availability(config) - _SERVICE_STATE["debrid"]["available"] = is_available - _SERVICE_STATE["debrid"]["reason"] = reason - _SERVICE_STATE["debrid"]["complete"] = True - if emit_debug: - status = 'ENABLED' if is_available else f'DISABLED - {reason or "Connection failed"}' - debug(f"Debrid: {status}", file=sys.stderr) - return is_available, reason - - -def check_mpv_availability() -> Tuple[bool, Optional[str]]: - """Check if MPV is available (installed and runnable). - - Returns: - Tuple of (is_available: bool, reason: Optional[str]) - """ - global _SERVICE_STATE - - if _SERVICE_STATE["mpv"]["complete"] and _SERVICE_STATE["mpv"]["available"] is not None: - return _SERVICE_STATE["mpv"]["available"], _SERVICE_STATE["mpv"]["reason"] - - import shutil - import subprocess - - logger.info("[MPV Health Check] Checking for MPV executable...") - - mpv_path = shutil.which("mpv") - if not mpv_path: - logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: Executable 'mpv' not found in PATH") - return False, "Executable 'mpv' not found in PATH" - - # Try to get version to confirm it works - try: - result = subprocess.run( - [mpv_path, "--version"], - capture_output=True, - text=True, - timeout=2 - ) - if result.returncode == 0: - version_line = result.stdout.split('\n')[0] - logger.info(f"[MPV Health Check] MPV is AVAILABLE ({version_line})") - return True, None - else: - reason = f"MPV returned non-zero exit code: {result.returncode}" - logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: {reason}") - return False, reason - except Exception as e: - reason = f"Error running MPV: {e}" - logger.warning(f"[MPV Health Check] ❌ MPV is UNAVAILABLE: {reason}") - return False, reason - - -def initialize_mpv_health_check(emit_debug: bool = True) -> Tuple[bool, Optional[str]]: - """Initialize MPV health check at startup and return (is_available, reason).""" - global _SERVICE_STATE - - logger.info("[Startup] Starting MPV health check...") - is_available, reason = check_mpv_availability() - _SERVICE_STATE["mpv"]["available"] = is_available - _SERVICE_STATE["mpv"]["reason"] = reason - _SERVICE_STATE["mpv"]["complete"] = True - - if emit_debug: - if is_available: - debug("MPV: ENABLED - All MPV features available", file=sys.stderr) - elif reason != "Not configured": - debug(f"MPV: DISABLED - {reason or 'Connection failed'}", file=sys.stderr) - - return is_available, reason - - -def check_matrix_availability(config: Dict[str, Any]) -> Tuple[bool, Optional[str]]: - """Check if Matrix homeserver is reachable and credentials are valid. - - Args: - config: Application configuration dictionary - - Returns: - Tuple of (is_available: bool, reason: Optional[str]) - """ - try: - import requests - matrix_conf = config.get('provider', {}).get('matrix', {}) - homeserver = matrix_conf.get('homeserver') - access_token = matrix_conf.get('access_token') - - if not homeserver: - return False, "Not configured" - - if not homeserver.startswith('http'): - homeserver = f"https://{homeserver}" - - # Check versions endpoint (no auth required) - try: - resp = requests.get(f"{homeserver}/_matrix/client/versions", timeout=5) - if resp.status_code != 200: - return False, f"Homeserver returned {resp.status_code}" - except Exception as e: - return False, f"Homeserver unreachable: {e}" - - # Check auth if token provided (whoami) - if access_token: - try: - headers = {"Authorization": f"Bearer {access_token}"} - resp = requests.get(f"{homeserver}/_matrix/client/v3/account/whoami", headers=headers, timeout=5) - if resp.status_code != 200: - return False, f"Authentication failed: {resp.status_code}" - except Exception as e: - return False, f"Auth check failed: {e}" - - return True, None - - except Exception as e: - return False, str(e) - - - -def initialize_matrix_health_check(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, Optional[str]]: - """Initialize Matrix health check at startup and return (is_available, reason).""" - global _SERVICE_STATE - - logger.info("[Startup] Starting Matrix health check...") - is_available, reason = check_matrix_availability(config) - _SERVICE_STATE["matrix"]["available"] = is_available - _SERVICE_STATE["matrix"]["reason"] = reason - _SERVICE_STATE["matrix"]["complete"] = True - - if emit_debug: - if is_available: - debug("Matrix: ENABLED - Homeserver reachable", file=sys.stderr) - elif reason != "Not configured": - debug(f"Matrix: DISABLED - {reason}", file=sys.stderr) - - return is_available, reason - - -# Unified getter functions for service availability - all use _SERVICE_STATE -def is_hydrus_available() -> bool: - """Check if Hydrus is available (from cached health check).""" - return _SERVICE_STATE["hydrus"]["available"] is True - - -def get_hydrus_unavailable_reason() -> Optional[str]: - """Get the reason why Hydrus is unavailable.""" - return _SERVICE_STATE["hydrus"]["reason"] if not is_hydrus_available() else None - - -def is_hydrus_check_complete() -> bool: - """Check if the Hydrus health check has been completed.""" - return _SERVICE_STATE["hydrus"]["complete"] - - -def disable_hydrus_features() -> None: - """Manually disable all Hydrus features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["hydrus"]["available"] = False - _SERVICE_STATE["hydrus"]["reason"] = "Manually disabled or lost connection" - logger.warning("[Hydrus] Features manually disabled") - - -def enable_hydrus_features() -> None: - """Manually enable Hydrus features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["hydrus"]["available"] = True - _SERVICE_STATE["hydrus"]["reason"] = None - logger.info("[Hydrus] Features manually enabled") - - -def is_debrid_available() -> bool: - """Check if Debrid is available (from cached health check).""" - return _SERVICE_STATE["debrid"]["available"] is True - - -def get_debrid_unavailable_reason() -> Optional[str]: - """Get the reason why Debrid is unavailable.""" - return _SERVICE_STATE["debrid"]["reason"] if not is_debrid_available() else None - - -def is_debrid_check_complete() -> bool: - """Check if the Debrid health check has been completed.""" - return _SERVICE_STATE["debrid"]["complete"] - - -def disable_debrid_features() -> None: - """Manually disable all Debrid features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["debrid"]["available"] = False - _SERVICE_STATE["debrid"]["reason"] = "Manually disabled or lost connection" - logger.warning("[Debrid] Features manually disabled") - - -def enable_debrid_features() -> None: - """Manually enable Debrid features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["debrid"]["available"] = True - _SERVICE_STATE["debrid"]["reason"] = None - logger.info("[Debrid] Features manually enabled") - - -def is_mpv_available() -> bool: - """Check if MPV is available (from cached health check).""" - return _SERVICE_STATE["mpv"]["available"] is True - -def get_mpv_unavailable_reason() -> Optional[str]: - """Get the reason why MPV is unavailable.""" - return _SERVICE_STATE["mpv"]["reason"] if not is_mpv_available() else None - - -def is_mpv_check_complete() -> bool: - """Check if the MPV health check has been completed.""" - return _SERVICE_STATE["mpv"]["complete"] - - -def disable_mpv_features() -> None: - """Manually disable all MPV features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["mpv"]["available"] = False - _SERVICE_STATE["mpv"]["reason"] = "Manually disabled or lost connection" - logger.warning("[MPV] Features manually disabled") - - -def enable_mpv_features() -> None: - """Manually enable MPV features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["mpv"]["available"] = True - _SERVICE_STATE["mpv"]["reason"] = None - logger.info("[MPV] Features manually enabled") - - -def is_matrix_available() -> bool: - """Check if Matrix is available (from cached health check).""" - return _SERVICE_STATE["matrix"]["available"] is True - - -def get_matrix_unavailable_reason() -> Optional[str]: - """Get the reason why Matrix is unavailable.""" - return _SERVICE_STATE["matrix"]["reason"] if not is_matrix_available() else None - - -def is_matrix_check_complete() -> bool: - """Check if the Matrix health check has been completed.""" - return _SERVICE_STATE["matrix"]["complete"] - - -def disable_matrix_features() -> None: - """Manually disable all Matrix features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["matrix"]["available"] = False - _SERVICE_STATE["matrix"]["reason"] = "Manually disabled or lost connection" - logger.warning("[Matrix] Features manually disabled") - - -def enable_matrix_features() -> None: - """Manually enable Matrix features (for testing/fallback).""" - global _SERVICE_STATE - _SERVICE_STATE["matrix"]["available"] = True - _SERVICE_STATE["matrix"]["reason"] = None - logger.info("[Matrix] Features manually enabled") - - -def initialize_local_library_scan(config: Dict[str, Any], emit_debug: bool = True) -> Tuple[bool, str]: - """Initialize and scan all folder stores at startup. - - Returns a tuple of (success, detail_message). - - Note: Individual store results are stored in _SERVICE_STATE["folder_stores"] - for the CLI to display as separate table rows. - - This ensures that any new files in configured folder stores are indexed - and their sidecar files are imported and cleaned up. - """ - from API.folder import LocalLibraryInitializer - from Store.Folder import Folder - - logger.info("[Startup] Starting folder store scans...") - - try: - # Get all configured folder stores from config - folder_sources = config.get("store", {}).get("folder", {}) - if not isinstance(folder_sources, dict) or not folder_sources: - if emit_debug: - debug("⚠️ Folder stores: SKIPPED - No folder stores configured", file=sys.stderr) - return False, "No folder stores configured" - - results = [] - total_new_files = 0 - total_sidecars = 0 - failed_stores = [] - store_results = {} - - for store_name, store_config in folder_sources.items(): - if not isinstance(store_config, dict): - continue - - store_path = store_config.get("path") - if not store_path: - continue - - try: - from pathlib import Path - storage_path = Path(str(store_path)).expanduser() - - if emit_debug: - debug(f"Scanning folder store '{store_name}' at: {storage_path}", file=sys.stderr) - - # Migrate the folder store to hash-based naming (only runs once per location) - Folder.migrate_location(str(storage_path)) - - initializer = LocalLibraryInitializer(storage_path) - stats = initializer.scan_and_index() - - # Accumulate stats - new_files = stats.get('files_new', 0) - sidecars = stats.get('sidecars_imported', 0) - total_new_files += new_files - total_sidecars += sidecars - - # Record result for this store - if new_files > 0 or sidecars > 0: - result_detail = f"New: {new_files}, Sidecars: {sidecars}" - if emit_debug: - debug(f" {store_name}: {result_detail}", file=sys.stderr) - else: - result_detail = "Up to date" - if emit_debug: - debug(f" {store_name}: {result_detail}", file=sys.stderr) - - results.append(f"{store_name}: {result_detail}") - store_results[store_name] = { - "path": str(storage_path), - "detail": result_detail, - "ok": True - } - - except Exception as e: - logger.error(f"[Startup] Failed to scan folder store '{store_name}': {e}", exc_info=True) - if emit_debug: - debug(f" {store_name}: ERROR - {e}", file=sys.stderr) - failed_stores.append(store_name) - store_results[store_name] = { - "path": str(store_config.get("path", "?")), - "detail": f"ERROR - {e}", - "ok": False - } - - # Store individual results for CLI to display - _SERVICE_STATE["folder_stores"] = store_results - - # Build detail message - if failed_stores: - detail = f"Scanned {len(results)} stores ({len(failed_stores)} failed); Total new: {total_new_files}, Sidecars: {total_sidecars}" - if emit_debug: - debug(f"Folder stores scan complete: {detail}", file=sys.stderr) - return len(failed_stores) < len(results), detail - else: - detail = f"Scanned {len(results)} stores; Total new: {total_new_files}, Sidecars: {total_sidecars}" - if emit_debug: - debug(f"Folder stores scan complete: {detail}", file=sys.stderr) - return True, detail - - except Exception as e: - logger.error(f"[Startup] Failed to scan folder stores: {e}", exc_info=True) - if emit_debug: - debug(f"⚠️ Folder stores: ERROR - Scan failed: {e}", file=sys.stderr) - return False, f"Scan failed: {e}" - - def initialize_cookies_check(config: Optional[Dict[str, Any]] = None, emit_debug: bool = True) -> Tuple[bool, str]: """Resolve cookies file path from config, falling back to cookies.txt in app root. diff --git a/models.py b/models.py index e8926a9..c4c75c7 100644 --- a/models.py +++ b/models.py @@ -532,6 +532,13 @@ class ProgressBar: Formatted progress string. """ percent = self.format_percent(percent_str) + # Some callers (e.g. yt-dlp hooks) may not provide a stable percent string. + # When we have byte counts, derive percent from them so the bar advances. + if (not percent_str or percent == 0.0) and downloaded is not None and total is not None and total > 0: + try: + percent = (float(downloaded) / float(total)) * 100.0 + except Exception: + percent = percent bar = self.build_bar(percent) # Format sizes diff --git a/readme.md b/readme.md index 269acf8..dbdc974 100644 --- a/readme.md +++ b/readme.md @@ -22,9 +22,9 @@ name="default" path="C:\\Media Machina" [store=hydrusnetwork] -name="home" -Hydrus-Client-API-Access-Key="..." -url="http://localhost:45869" +NAME="home" +API="..." +URL="http://localhost:45869" [provider=OpenLibrary] email="user@example.com"