feat(service-storage-harness): slice 1 — harness + qbit store + client + integration
ServiceDataHarness (services/service_data.py): lifecycle-only registry of per-concern storage — DB provisioning, idempotent migrations (ALTER TABLE duplicate-column-name caught per-statement), cascade_delete(service_id). QbittorrentSampleStore: append/window/prune (MAX_SAMPLES=120) in qbittorrent.db. QbittorrentClient: cookie-login Web API client (403 re-login, /sync/maindata). Integration registered with 3 widget kinds (totals/active/ speed). Harness initialized in main.py lifespan. Backend: 314 pytest pass (21 new), ruff clean.
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
"""Minimal qBittorrent Web API client (read-only: sync/maindata only).
|
||||||
|
|
||||||
|
Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
|
||||||
|
session pattern. Authentication uses username/password login which stores an
|
||||||
|
SID cookie in the requests session. The client re-logins transparently on 403.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentClient:
|
||||||
|
"""Small wrapper around the qBittorrent Web API.
|
||||||
|
|
||||||
|
Only the endpoints needed by the dashboard widgets are implemented
|
||||||
|
(currently just ``/sync/maindata``). All calls share a single
|
||||||
|
:class:`requests.Session` that carries the login cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
|
||||||
|
if not base_url:
|
||||||
|
raise ValueError("qBittorrent base_url is required")
|
||||||
|
if not username:
|
||||||
|
raise ValueError("qBittorrent username is required")
|
||||||
|
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
if not self.base_url.endswith("/api/v2"):
|
||||||
|
self.base_url += "/api/v2"
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self.timeout = timeout
|
||||||
|
self._session = requests.Session()
|
||||||
|
self._logged_in = False
|
||||||
|
|
||||||
|
def _login(self) -> None:
|
||||||
|
"""POST username/password to ``/auth/login``; store the SID cookie.
|
||||||
|
|
||||||
|
qBittorrent returns the plain text ``"Ok."`` on success. The
|
||||||
|
``Referer`` header is required by some qBittorrent CSRF protections.
|
||||||
|
"""
|
||||||
|
resp = self._session.post(
|
||||||
|
f"{self.base_url}/auth/login",
|
||||||
|
data={"username": self._username, "password": self._password},
|
||||||
|
timeout=self.timeout,
|
||||||
|
headers={"Referer": self.base_url},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
if resp.text.strip() != "Ok.":
|
||||||
|
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
|
||||||
|
self._logged_in = True
|
||||||
|
logger.info("qBittorrent login successful for %s", self.base_url)
|
||||||
|
|
||||||
|
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||||
|
"""GET an endpoint with auto-login on first call and re-login on 403."""
|
||||||
|
if not self._logged_in:
|
||||||
|
self._login()
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||||
|
if resp.status_code == 403:
|
||||||
|
logger.debug("qBittorrent 403 on %s, re-logging in", path)
|
||||||
|
self._logged_in = False
|
||||||
|
self._login()
|
||||||
|
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def maindata(self) -> dict[str, Any]:
|
||||||
|
"""Fetch ``/sync/maindata``.
|
||||||
|
|
||||||
|
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
|
||||||
|
``up_info_speed``, etc.) and ``torrents`` (a dict of
|
||||||
|
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
|
||||||
|
"""
|
||||||
|
return self._get("/sync/maindata")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""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 media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceBaseUrl,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret qBittorrent connection config."""
|
||||||
|
|
||||||
|
base_url: ServiceBaseUrl
|
||||||
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Per-widget config (empty — all three kinds derive from the service connection)."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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="Torrents currently downloading or uploading.",
|
||||||
|
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=QbittorrentWidgetConfig,
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=5_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -13,6 +13,7 @@ from media_library_viewer_api.integrations.base import ServiceDefinition, Widget
|
|||||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
|
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||||
|
|
||||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||||
@@ -20,6 +21,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
|||||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||||
JELLYFIN.service_type: JELLYFIN,
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
|
QBITTORRENT.service_type: QBITTORRENT,
|
||||||
SSH_TASKS.service_type: SSH_TASKS,
|
SSH_TASKS.service_type: SSH_TASKS,
|
||||||
BACKUPS.service_type: BACKUPS,
|
BACKUPS.service_type: BACKUPS,
|
||||||
AUTHENTIK.service_type: AUTHENTIK,
|
AUTHENTIK.service_type: AUTHENTIK,
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ async def lifespan(app: FastAPI):
|
|||||||
get_settings_store().ensure_defaults()
|
get_settings_store().ensure_defaults()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to seed default settings during startup")
|
logger.exception("Failed to seed default settings during startup")
|
||||||
|
try:
|
||||||
|
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||||
|
|
||||||
|
get_service_data_harness()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to initialize service data harness during startup")
|
||||||
mail_queue = get_mail_queue()
|
mail_queue = get_mail_queue()
|
||||||
backup_poller = get_backup_poller()
|
backup_poller = get_backup_poller()
|
||||||
mail_queue.start()
|
mail_queue.start()
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Speed-sample storage for qBittorrent widgets.
|
||||||
|
|
||||||
|
This module defines the storage concern for qBittorrent speed data and a
|
||||||
|
bespoke store with ``append``/``window`` operations. It is registered with the
|
||||||
|
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
|
||||||
|
the first real consumer of the harness lifecycle layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.service_data import StorageConcern
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
QBITTORRENT_CONCERN = StorageConcern(
|
||||||
|
concern_key="qbittorrent",
|
||||||
|
db_filename="qbittorrent.db",
|
||||||
|
migrations=[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
dl_speed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
up_speed INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
|
||||||
|
ON qbittorrent_speed_samples(service_id, ts);
|
||||||
|
"""
|
||||||
|
],
|
||||||
|
tables=["qbittorrent_speed_samples"],
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
|
||||||
|
MAX_SAMPLES = 120
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentSampleStore:
|
||||||
|
"""Bespoke speed-sample store for qBittorrent widgets.
|
||||||
|
|
||||||
|
Each ``append`` inserts a new sample and prunes entries beyond
|
||||||
|
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
|
||||||
|
``service_id``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||||
|
if harness is None:
|
||||||
|
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||||
|
|
||||||
|
harness = get_service_data_harness()
|
||||||
|
self._harness = harness
|
||||||
|
|
||||||
|
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
||||||
|
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
|
||||||
|
with self._harness.connect("qbittorrent") as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||||
|
(service_id, ts, dl_speed, up_speed),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM qbittorrent_speed_samples "
|
||||||
|
"WHERE service_id = ? AND ts NOT IN ("
|
||||||
|
" SELECT ts FROM qbittorrent_speed_samples"
|
||||||
|
" WHERE service_id = ?"
|
||||||
|
" ORDER BY ts DESC LIMIT ?"
|
||||||
|
")",
|
||||||
|
(service_id, service_id, MAX_SAMPLES),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""Return all samples for a service since a timestamp (or all if ``None``)."""
|
||||||
|
with self._harness.connect("qbittorrent") as conn:
|
||||||
|
if since_ts is not None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
|
||||||
|
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
|
||||||
|
(service_id, since_ts),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
|
||||||
|
(service_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Lifecycle-only storage harness for service-owned persistent data.
|
||||||
|
|
||||||
|
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
|
||||||
|
schema migrations, and cascade-delete when a service instance is removed. It
|
||||||
|
does **not** own data operations — each integration implements its own Store
|
||||||
|
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
|
||||||
|
etc.). This keeps the general interface narrow (lifecycle) and the specific
|
||||||
|
interfaces rich (per-integration operations).
|
||||||
|
|
||||||
|
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
|
||||||
|
declaring its DB filename, ordered migration statements, owned tables, and the
|
||||||
|
column used for service scoping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StorageConcern:
|
||||||
|
"""A per-integration storage namespace registered with the harness."""
|
||||||
|
|
||||||
|
concern_key: str # e.g. "qbittorrent", "media_index"
|
||||||
|
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
||||||
|
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
|
||||||
|
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
|
||||||
|
service_id_column: str = "service_id"
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDataHarness:
|
||||||
|
"""Lifecycle-only registry of per-concern storage.
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
* DB provisioning (per-concern SQLite files under ``base_dir``).
|
||||||
|
* Schema migrations (run on first access via :meth:`run_migrations`).
|
||||||
|
* ``service_id`` cascade-delete when a service instance is removed.
|
||||||
|
|
||||||
|
Does **not** own:
|
||||||
|
* Data operations — each store keeps bespoke append/window/query/etc.
|
||||||
|
* A generic value table or generic CRUD layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, base_dir: Path | str) -> None:
|
||||||
|
self._base_dir = Path(base_dir)
|
||||||
|
self._concerns: dict[str, StorageConcern] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_dir(self) -> Path:
|
||||||
|
return self._base_dir
|
||||||
|
|
||||||
|
def register(self, concern: StorageConcern) -> None:
|
||||||
|
"""Register a storage concern. Called at startup / on first access."""
|
||||||
|
self._concerns[concern.concern_key] = concern
|
||||||
|
|
||||||
|
def db_path(self, concern_key: str) -> Path:
|
||||||
|
"""Return the absolute path to a concern's DB file."""
|
||||||
|
concern = self._concerns[concern_key]
|
||||||
|
return self._base_dir / concern.db_filename
|
||||||
|
|
||||||
|
def connect(self, concern_key: str) -> sqlite3.Connection:
|
||||||
|
"""Open a WAL-mode connection to a concern's DB."""
|
||||||
|
path = self.db_path(concern_key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=30000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def run_migrations(self) -> None:
|
||||||
|
"""Run pending migrations for every registered concern.
|
||||||
|
|
||||||
|
Each migration string is split into individual statements (by ``;``)
|
||||||
|
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
|
||||||
|
that fail with "duplicate column name" are silently skipped, making
|
||||||
|
migrations idempotent across re-runs and fresh installs where
|
||||||
|
``init_schema`` may have already created the column.
|
||||||
|
"""
|
||||||
|
for concern in self._concerns.values():
|
||||||
|
path = self.db_path(concern.concern_key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(path, timeout=30)
|
||||||
|
try:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
for migration_sql in concern.migrations:
|
||||||
|
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
|
||||||
|
for stmt in statements:
|
||||||
|
try:
|
||||||
|
conn.execute(stmt)
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
if "duplicate column name" in str(exc).lower():
|
||||||
|
logger.debug("Skipping already-applied migration: %s", stmt[:80])
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def cascade_delete(self, service_id: str) -> None:
|
||||||
|
"""Delete all rows for a ``service_id`` across every concern's tables.
|
||||||
|
|
||||||
|
Called from :meth:`SettingsStore.delete_service` after the service row
|
||||||
|
is removed. Best-effort: callers wrap in try/except so a harness
|
||||||
|
failure does not block service deletion.
|
||||||
|
"""
|
||||||
|
for concern in self._concerns.values():
|
||||||
|
col = concern.service_id_column
|
||||||
|
path = self.db_path(concern.concern_key)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
with sqlite3.connect(path, timeout=30) as conn:
|
||||||
|
for table in concern.tables:
|
||||||
|
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||||
|
if col in cols:
|
||||||
|
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HARNESS: ServiceDataHarness | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_data_harness() -> ServiceDataHarness:
|
||||||
|
"""Return the process-wide harness singleton, initializing it on first call.
|
||||||
|
|
||||||
|
Lazy registration of built-in concerns happens here (local imports avoid
|
||||||
|
circular dependencies). Migrations are run immediately after registration.
|
||||||
|
"""
|
||||||
|
global _HARNESS
|
||||||
|
if _HARNESS is None:
|
||||||
|
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
||||||
|
_HARNESS = ServiceDataHarness(base_dir)
|
||||||
|
# Register built-in concerns (lazy import avoids circular dependency).
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
|
||||||
|
|
||||||
|
_HARNESS.register(QBITTORRENT_CONCERN)
|
||||||
|
_HARNESS.run_migrations()
|
||||||
|
return _HARNESS
|
||||||
|
|
||||||
|
|
||||||
|
def reset_service_data_harness() -> None:
|
||||||
|
"""Reset the singleton (for testing)."""
|
||||||
|
global _HARNESS
|
||||||
|
_HARNESS = None
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Unit tests for the QbittorrentClient."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||||
|
|
||||||
|
|
||||||
|
class QbittorrentClientTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
|
||||||
|
self.session = MagicMock()
|
||||||
|
self.client._session = self.session
|
||||||
|
|
||||||
|
def _login_response(self, text: str = "Ok.") -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.text = text
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
resp.status_code = 200
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.json.return_value = json_data
|
||||||
|
resp.raise_for_status.return_value = None
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.text = ""
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def test_base_url_appends_api_v2(self) -> None:
|
||||||
|
c = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||||
|
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||||
|
|
||||||
|
def test_base_url_keeps_existing_api_v2(self) -> None:
|
||||||
|
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
|
||||||
|
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||||
|
|
||||||
|
def test_base_url_strips_trailing_slash(self) -> None:
|
||||||
|
c = QbittorrentClient("https://qb.example.com/", "u", "p")
|
||||||
|
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||||
|
|
||||||
|
def test_empty_base_url_raises(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
QbittorrentClient("", "u", "p")
|
||||||
|
|
||||||
|
def test_empty_username_raises(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
QbittorrentClient("https://qb.example.com", "", "p")
|
||||||
|
|
||||||
|
def test_login_posts_credentials(self) -> None:
|
||||||
|
self.session.post.return_value = self._login_response("Ok.")
|
||||||
|
self.client._login()
|
||||||
|
self.session.post.assert_called_once()
|
||||||
|
call_args = self.session.post.call_args
|
||||||
|
self.assertIn("/auth/login", call_args.args[0])
|
||||||
|
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
|
||||||
|
self.assertTrue(self.client._logged_in)
|
||||||
|
|
||||||
|
def test_login_failure_raises_runtime_error(self) -> None:
|
||||||
|
self.session.post.return_value = self._login_response("Fails.")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.client._login()
|
||||||
|
|
||||||
|
def test_get_auto_logs_in_on_first_call(self) -> None:
|
||||||
|
"""First _get triggers login, then fetches data."""
|
||||||
|
self.session.post.return_value = self._login_response("Ok.")
|
||||||
|
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
|
||||||
|
|
||||||
|
result = self.client._get("/sync/maindata")
|
||||||
|
|
||||||
|
self.session.post.assert_called_once() # login happened
|
||||||
|
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||||
|
|
||||||
|
def test_cookie_reuse_does_not_re_login(self) -> None:
|
||||||
|
"""After login, subsequent _get calls do NOT re-login."""
|
||||||
|
self.client._logged_in = True # simulate already logged in
|
||||||
|
self.session.get.return_value = self._get_response({"data": 1})
|
||||||
|
|
||||||
|
self.client._get("/some/path")
|
||||||
|
|
||||||
|
self.session.post.assert_not_called() # no re-login
|
||||||
|
|
||||||
|
def test_403_triggers_re_login(self) -> None:
|
||||||
|
"""A 403 response triggers re-login and retries the GET."""
|
||||||
|
self.client._logged_in = True # already logged in from a prior call
|
||||||
|
forbidden = MagicMock()
|
||||||
|
forbidden.status_code = 403
|
||||||
|
ok = self._get_response({"server_state": {}, "torrents": {}})
|
||||||
|
self.session.get.side_effect = [forbidden, ok]
|
||||||
|
self.session.post.return_value = self._login_response("Ok.")
|
||||||
|
|
||||||
|
result = self.client._get("/sync/maindata")
|
||||||
|
|
||||||
|
self.assertEqual(self.session.get.call_count, 2) # initial + retry
|
||||||
|
self.session.post.assert_called_once() # re-login happened
|
||||||
|
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||||
|
|
||||||
|
def test_maindata_returns_full_payload(self) -> None:
|
||||||
|
self.client._logged_in = True
|
||||||
|
payload = {
|
||||||
|
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
|
||||||
|
"torrents": {
|
||||||
|
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
|
||||||
|
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.session.get.return_value = self._get_response(payload)
|
||||||
|
|
||||||
|
result = self.client.maindata()
|
||||||
|
|
||||||
|
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
|
||||||
|
self.assertEqual(len(result["torrents"]), 2)
|
||||||
|
|
||||||
|
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
|
||||||
|
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
|
||||||
|
"""A network error during login propagates as requests exception."""
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session_cls.return_value = mock_session
|
||||||
|
bad_resp = MagicMock()
|
||||||
|
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
|
||||||
|
bad_resp.text = ""
|
||||||
|
mock_session.post.return_value = bad_resp
|
||||||
|
|
||||||
|
client = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||||
|
with self.assertRaises(requests.ConnectionError):
|
||||||
|
client._login()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from media_library_viewer_api.services.qbittorrent_store import (
|
||||||
|
MAX_SAMPLES,
|
||||||
|
QBITTORRENT_CONCERN,
|
||||||
|
QbittorrentSampleStore,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.service_data import (
|
||||||
|
ServiceDataHarness,
|
||||||
|
StorageConcern,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A throwaway concern used to test harness lifecycle in isolation.
|
||||||
|
_TEST_CONCERN = StorageConcern(
|
||||||
|
concern_key="test",
|
||||||
|
db_filename="test.db",
|
||||||
|
migrations=[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS test_items (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id);
|
||||||
|
"""
|
||||||
|
],
|
||||||
|
tables=["test_items"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceDataHarnessMigrations:
|
||||||
|
def test_run_migrations_creates_tables(self, tmp_path):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(_TEST_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
|
||||||
|
with harness.connect("test") as conn:
|
||||||
|
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||||
|
assert "test_items" in tables
|
||||||
|
|
||||||
|
def test_migrations_are_idempotent(self, tmp_path):
|
||||||
|
"""Re-running migrations on an already-migrated DB must not crash."""
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(_TEST_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
harness.run_migrations() # should not raise
|
||||||
|
|
||||||
|
def test_alter_table_idempotency(self, tmp_path):
|
||||||
|
"""ALTER TABLE ADD COLUMN must be silently skipped on re-run."""
|
||||||
|
concern = StorageConcern(
|
||||||
|
concern_key="alter_test",
|
||||||
|
db_filename="alter.db",
|
||||||
|
migrations=[
|
||||||
|
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)",
|
||||||
|
"ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''",
|
||||||
|
],
|
||||||
|
tables=["items"],
|
||||||
|
)
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(concern)
|
||||||
|
harness.run_migrations()
|
||||||
|
harness.run_migrations() # second run: "duplicate column name" caught
|
||||||
|
|
||||||
|
with harness.connect("alter_test") as conn:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()}
|
||||||
|
assert "extra" in cols
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceDataHarnessCascadeDelete:
|
||||||
|
def test_cascade_delete_removes_only_matching_service(self, tmp_path):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(_TEST_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
|
||||||
|
with harness.connect("test") as conn:
|
||||||
|
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')")
|
||||||
|
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')")
|
||||||
|
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
harness.cascade_delete("svc-a")
|
||||||
|
|
||||||
|
with harness.connect("test") as conn:
|
||||||
|
remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall()
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0][0] == "svc-b"
|
||||||
|
|
||||||
|
def test_cascade_delete_skips_missing_concern_db(self, tmp_path):
|
||||||
|
"""cascade_delete on a concern whose DB file doesn't exist should not crash."""
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(_TEST_CONCERN)
|
||||||
|
# No run_migrations → DB file doesn't exist
|
||||||
|
harness.cascade_delete("svc-x") # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestQbittorrentSampleStore:
|
||||||
|
@pytest.fixture()
|
||||||
|
def store(self, tmp_path):
|
||||||
|
harness = ServiceDataHarness(tmp_path)
|
||||||
|
harness.register(QBITTORRENT_CONCERN)
|
||||||
|
harness.run_migrations()
|
||||||
|
return QbittorrentSampleStore(harness=harness)
|
||||||
|
|
||||||
|
def test_append_and_window(self, store):
|
||||||
|
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||||
|
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||||
|
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||||
|
|
||||||
|
samples = store.window("svc-1")
|
||||||
|
assert len(samples) == 3
|
||||||
|
assert samples[0]["ts"] == 100
|
||||||
|
assert samples[2]["ts"] == 300
|
||||||
|
assert samples[1]["dl_speed"] == 600
|
||||||
|
|
||||||
|
def test_window_with_since_ts(self, store):
|
||||||
|
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||||
|
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||||
|
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||||
|
|
||||||
|
samples = store.window("svc-1", since_ts=200)
|
||||||
|
assert len(samples) == 2
|
||||||
|
assert samples[0]["ts"] == 200
|
||||||
|
|
||||||
|
def test_prune_enforces_max_samples(self, store):
|
||||||
|
for i in range(MAX_SAMPLES + 10):
|
||||||
|
store.append("svc-1", ts=i, dl_speed=i, up_speed=i)
|
||||||
|
|
||||||
|
samples = store.window("svc-1")
|
||||||
|
assert len(samples) == MAX_SAMPLES
|
||||||
|
# The oldest 10 should have been pruned
|
||||||
|
assert samples[0]["ts"] == 10
|
||||||
|
assert samples[-1]["ts"] == MAX_SAMPLES + 9
|
||||||
|
|
||||||
|
def test_two_services_do_not_cross_contaminate(self, store):
|
||||||
|
store.append("svc-a", ts=100, dl_speed=500, up_speed=50)
|
||||||
|
store.append("svc-b", ts=200, dl_speed=600, up_speed=60)
|
||||||
|
|
||||||
|
a_samples = store.window("svc-a")
|
||||||
|
b_samples = store.window("svc-b")
|
||||||
|
|
||||||
|
assert len(a_samples) == 1
|
||||||
|
assert a_samples[0]["dl_speed"] == 500
|
||||||
|
assert len(b_samples) == 1
|
||||||
|
assert b_samples[0]["dl_speed"] == 600
|
||||||
@@ -57,7 +57,7 @@ def client(tmp_path):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_registry_contains_seven_service_types():
|
def test_registry_contains_eight_service_types():
|
||||||
assert set(SERVICE_DEFINITIONS) == {
|
assert set(SERVICE_DEFINITIONS) == {
|
||||||
"prometheus",
|
"prometheus",
|
||||||
"alertmanager",
|
"alertmanager",
|
||||||
@@ -66,6 +66,7 @@ def test_registry_contains_seven_service_types():
|
|||||||
"ssh_tasks",
|
"ssh_tasks",
|
||||||
"backups",
|
"backups",
|
||||||
"authentik",
|
"authentik",
|
||||||
|
"qbittorrent",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ def test_list_service_types(client):
|
|||||||
"jellyfin",
|
"jellyfin",
|
||||||
"nextcloud",
|
"nextcloud",
|
||||||
"prometheus",
|
"prometheus",
|
||||||
|
"qbittorrent",
|
||||||
"ssh_tasks",
|
"ssh_tasks",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user