146 lines
5.1 KiB
Python
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,
|
|
)
|