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,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
|
||||
Reference in New Issue
Block a user