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:
Developer
2026-07-09 08:22:36 +00:00
parent 9a251db23c
commit e7bd0afdd1
9 changed files with 685 additions and 1 deletions
@@ -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.nextcloud import DEFINITION as NEXTCLOUD
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
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
@@ -20,6 +21,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN,
NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
@@ -52,6 +52,12 @@ async def lifespan(app: FastAPI):
get_settings_store().ensure_defaults()
except Exception:
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()
backup_poller = get_backup_poller()
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