"""Base classes for service integrations. A *service definition* is a closed, compile-time description of an external service the app can talk to (Jellyfin, Prometheus, …). Each definition declares: * its non-secret ``config_schema`` (derived from a Pydantic model), * the secret fields it accepts (API keys / tokens), * the widget kinds it can contribute to the dashboard (each with its own Pydantic-derived config schema). Definitions live in :mod:`media_library_viewer_api.integrations` modules and are assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS` map. There is no runtime plugin loading. """ from __future__ import annotations import asyncio from dataclasses import dataclass, field from typing import TYPE_CHECKING, Annotated, Any, Callable import requests from pydantic import BaseModel, BeforeValidator, Field if TYPE_CHECKING: from media_library_viewer_api.services.settings_store import SettingsStore def _validate_service_base_url(value: Any) -> str: """Require an absolute http(s) URL for service ``base_url`` fields. Relative hosts (e.g. ``example.com``) break downstream HTTP clients because ``requests`` treats them as relative paths, so we fail fast with a clear error instead of letting the call silently malfunction. """ if not isinstance(value, str): raise ValueError("base_url must be a string starting with http:// or https://") text = value.strip() if not text: raise ValueError("base_url must not be empty") lowered = text.lower() if not (lowered.startswith("http://") or lowered.startswith("https://")): raise ValueError("base_url must start with http:// or https:// (include the schema)") return text #: Shared annotated type for service ``base_url`` fields. applying the validator #: uniformly across every integration so missing schemas are rejected at the #: config boundary with a helpful message. ServiceBaseUrl = Annotated[ str, Field(description="Absolute URL including the http:// or https:// schema."), BeforeValidator(_validate_service_base_url), ] class ServiceConfigBase(BaseModel): """Base for per-service non-secret config models. Subclass this in each integration module and declare the connection fields. The JSON schema is derived via ``model_json_schema()`` and exposed to the UI. Connection URLs should use the :data:`ServiceBaseUrl` type so the ``http(s)://`` schema is enforced consistently across integrations. """ class WidgetConfigBase(BaseModel): """Base for per-widget config models. Subclass this for each widget kind a service provides. Widget configs never hold secrets; credentials live on the parent service record. """ model_config = {"extra": "forbid"} @dataclass(frozen=True) class SecretField: """A secret field stored encrypted on the service record.""" key: str label: str required: bool = False helper: str | None = None @dataclass(frozen=True) class WidgetKind: """A widget kind contributed by a service definition.""" kind: str name: str description: str config_schema: dict[str, Any] default_config: dict[str, Any] = field(default_factory=dict) refresh_interval_ms: int = 0 config_model: type[WidgetConfigBase] | None = None @dataclass(frozen=True) class TestResult: """Outcome of a credential/connectivity test for a service instance.""" ok: bool detail: str evidence: str | None = None #: A test routine receives (config, secrets, store). The store is needed for #: ssh_tasks (SSH-key resolution). Other types ignore it. TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult] @dataclass(frozen=True) class ServiceDefinition: """Closed description of an external service type.""" service_type: str name: str description: str config_model: type[ServiceConfigBase] secret_fields: list[SecretField] widget_kinds: list[WidgetKind] test_callable: TestCallable | None = None @property def config_schema(self) -> dict[str, Any]: """JSON schema for the service's non-secret config.""" return self.config_model.model_json_schema() @property def secret_keys(self) -> set[str]: return {sf.key for sf in self.secret_fields} def widget_kind(self, kind: str) -> WidgetKind | None: for wk in self.widget_kinds: if wk.kind == kind: return wk return None def widget_kind( kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], *, default_config: dict[str, Any] | None = None, refresh_interval_ms: int = 0, ) -> WidgetKind: """Build a :class:`WidgetKind` from a Pydantic widget-config model.""" schema = model_cls.model_json_schema() # Strip Pydantic's title noise so the exposed schema stays clean. schema.pop("title", None) return WidgetKind( kind=kind, name=name, description=description, config_schema=schema, default_config=dict(default_config or {}), refresh_interval_ms=refresh_interval_ms, config_model=model_cls, ) def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]: """Validate a config dict against a Pydantic model and return the cleaned dict.""" instance = model_cls.model_validate(config or {}) return instance.model_dump(exclude_none=True) def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult: """Map a common connection/auth exception to a human-friendly TestResult. Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus HTTP-client patterns from the widget sources. Each per-type test routine calls this for unexpected exceptions, but handles its **type-specific** auth failures directly (e.g., qBit ``"Fails."``). """ message = str(exc) lowered = message.lower() # Auth failures (HTTP 401/403) if isinstance(exc, requests.HTTPError): status_code = exc.response.status_code if exc.response is not None else 0 if status_code in (401, 403): return TestResult( ok=False, detail=f"Authentication failed — the service rejected the credentials ({status_code}).", ) if "authentication failed" in lowered or "no authentication methods available" in lowered: return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.") # Timeout (before OSError check, since requests.Timeout is a subclass of OSError) if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)): return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.") # Connection refused / DNS / unreachable if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)): if ( "name or service not known" in lowered or "nodename nor servname" in lowered or "getaddrinfo failed" in lowered ): return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.") return TestResult( ok=False, detail="Connection refused — the service is not reachable at the configured address.", ) # SSL / certificate errors if "ssl" in lowered or "certificate" in lowered: return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.") # SSH banner (from test_machine_ssh pattern) if "protocol banner" in lowered: return TestResult( ok=False, detail="SSH banner not received — confirm the SSH service is running and the port is correct.", ) # Fallback prefix = f"{context}: " if context else "" return TestResult(ok=False, detail=f"{prefix}{message[:200]}")