"""qBittorrent service definition. Declares the config model (base URL + timeout), secret fields (username + password), and three widget kinds (totals, active, speed). Models on :mod:`media_library_viewer_api.integrations.prometheus`. """ from __future__ import annotations from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator from media_library_viewer_api.clients.qbittorrent import QbittorrentClient from media_library_viewer_api.integrations.base import ( SecretField, ServiceBaseUrl, ServiceConfigBase, ServiceDefinition, TestResult, WidgetConfigBase, translate_connection_error, widget_kind, ) if TYPE_CHECKING: from media_library_viewer_api.services.settings_store import SettingsStore def test_connection( config: dict[str, Any], secrets: dict[str, str], store: SettingsStore, ) -> TestResult: """Login + probe maindata; surface auth failures specifically.""" try: base_url = str(config.get("base_url") or "") username = str(secrets.get("username") or "") password = str(secrets.get("password") or "") timeout = int(config.get("timeout_seconds") or 60) client = QbittorrentClient(base_url, username, password, timeout=timeout) data = client.maindata() version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected") return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version) except RuntimeError as exc: lowered = str(exc).lower() if "invalid username or password" in lowered: return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.") # Gateway timeout, wrong URL/path, empty body, etc. — surface the real # reason instead of masking every login error as an auth failure. return translate_connection_error(exc, context="qBittorrent") except Exception as exc: return translate_connection_error(exc, context="qBittorrent") class QbittorrentConfig(ServiceConfigBase): """Non-secret qBittorrent connection and sampling config.""" base_url: ServiceBaseUrl timeout_seconds: int = Field(default=60, ge=1, le=300) polling_enabled: bool = Field(default=True, description="Collect speed samples without an open dashboard") poll_interval_seconds: int = Field(default=15, ge=5, le=300, description="Seconds between speed samples") sample_retention_seconds: int = Field( default=1_800, ge=60, le=86_400, description="How long speed samples remain available", ) sample_max_rows: int = Field( default=1_200, ge=60, le=1_200, description="Maximum speed samples retained per service", ) class QbittorrentWidgetConfig(WidgetConfigBase): """Per-widget config for totals/active (empty — derived from the service connection).""" pass class QbittorrentSpeedWidgetConfig(WidgetConfigBase): """Speed chart config. The source returns raw bytes/sec; the frontend scales.""" window_seconds: int | Literal["all"] = 1_800 unit: Literal[ "none", "bytes", "bytes_per_sec", "bits_per_sec", "bits", "percent", "seconds", ] = "bytes_per_sec" scale: Literal["auto", "k", "m", "g", "t"] = "auto" @field_validator("window_seconds") @classmethod def validate_window_seconds(cls, value: int | str) -> int | str: """Allow all retained samples while bounding explicit numeric windows.""" if value == "all": return value if not isinstance(value, int) or not 60 <= value <= 86_400: raise ValueError("window_seconds must be between 60 and 86400, or 'all'") return value DEFINITION = ServiceDefinition( service_type="qbittorrent", name="qBittorrent", description="Torrent client activity, speeds, and item counts.", config_model=QbittorrentConfig, secret_fields=[ SecretField(key="username", label="Username", required=True), SecretField(key="password", label="Password", required=True, helper="Stored encrypted"), ], widget_kinds=[ widget_kind( kind="totals", name="Totals", description="Count of all listed torrents, broken down by state.", model_cls=QbittorrentWidgetConfig, default_config={}, refresh_interval_ms=30_000, ), widget_kind( kind="active", name="Active torrents", description="All active download/upload work, including queued and stalled transfers.", model_cls=QbittorrentWidgetConfig, default_config={}, refresh_interval_ms=15_000, ), widget_kind( kind="speed", name="Speed chart", description="Live download/upload speed over a short window.", model_cls=QbittorrentSpeedWidgetConfig, default_config={"window_seconds": 1_800, "unit": "bytes_per_sec", "scale": "auto"}, refresh_interval_ms=15_000, ), ], test_callable=test_connection, )