Files
Medios-Macina/hydrus_health_check.py
nose 30eb628aa3 hh
2025-12-13 00:18:30 -08:00

551 lines
21 KiB
Python

"""Hydrus API health check and initialization.
Provides startup health checks for Hydrus API availability and gracefully
disables Hydrus features if the API is unavailable.
"""
import logging
import sys
from SYS.logger import log, debug
from typing import Tuple, Optional, Dict, Any
from pathlib import Path
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},
}
# 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.
Returns a tuple of (found, detail_message).
"""
global _COOKIES_FILE_PATH
try:
from config import resolve_cookies_path
cookies_path = resolve_cookies_path(config or {}, script_dir=Path(__file__).parent)
except Exception:
cookies_path = None
if cookies_path and cookies_path.exists():
_COOKIES_FILE_PATH = str(cookies_path)
if emit_debug:
debug(f"Cookies: ENABLED - Found cookies file", file=sys.stderr)
return True, str(cookies_path)
else:
_COOKIES_FILE_PATH = None
return False, "Not found"
def get_cookies_file_path() -> Optional[str]:
"""Get the path to the cookies.txt file if it exists."""
return _COOKIES_FILE_PATH