"""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: lowered = str(exc).lower() if "duplicate column name" in lowered or "no such table" in lowered: logger.debug("Skipping migration (already applied or table absent): %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) from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN _HARNESS.register(MEDIA_INDEX_CONCERN) _HARNESS.run_migrations() return _HARNESS def reset_service_data_harness() -> None: """Reset the singleton (for testing).""" global _HARNESS _HARNESS = None