Files
manage/backend/src/media_library_viewer_api/integrations/qbittorrent.py
T
2026-07-15 18:55:57 +00:00

146 lines
5.1 KiB
Python

"""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,
)