# SDD Design: Service Storage Harness (with qBittorrent widgets + MediaIndex migration)

**Change:** `service-storage-harness`
**Phase:** design
**Date:** 2026-07-09

> Grounded in `proposal.md` (Q1–Q5 resolved) and the already-archived
> `prometheus-direct-charting` change. No source changes in this phase.

## 0. Source findings (read before anything else)

The proposal was written against a stale project map. Reading actual source
surfaced three findings that shape the design. Trust source, not the map.

### 0.1 The media worker ALREADY threads `service_id` — but it is NOT persisted in rows

`workers/media_index_worker.py` already accepts `--service-id` via argparse
(`run_build(final_index_path, staging_index_path, service_id="")`), and
`_resolve_jellyfin(service_id)` resolves the Jellyfin client + user_id from the
settings store. The media router's `_start_worker(index, service_id)` and
`post_build_index` already pass `jellyfin_service_id` through to the worker.

**But:** `service_id` is used ONLY to pick the Jellyfin connection. It is never
stored in the `media_items` table (which has no `service_id` column). The
migration must close this gap: `replace_items` must scope its DELETE+INSERT by
`service_id`, and `query` must filter by it. The plumbing to get `service_id`
into the worker already exists — only the storage layer is missing.

### 0.2 `media_items` is cleared globally on every build

`replace_items` does `DELETE FROM media_items` (no WHERE clause). This means a
build for Jellyfin instance A wipes instance B's rows. After migration this
becomes `DELETE FROM media_items WHERE service_id = ?`.

### 0.3 The `delete_service` cascade already exists — the harness hooks into it

`SettingsStore.delete_service` already cascade-deletes `dashboard_widgets WHERE
service_id = ?` (with a PRAGMA-guarded column check). The harness cascade-delete
hooks into the same place: after the service row is deleted, iterate registered
concerns and delete that `service_id` from each owned table.

### 0.4 DB path is a module constant, not in config.py

`DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")` in
`media_index_impl.py`. The file stays at this path (per the locked topology
decision). The harness must not move it.

---

## 1. Architecture overview

```
┌──────────────────────────────────────────────────────────────────┐
│                     ServiceDataHarness                            │
│  (services/service_data.py — LIFECYCLE ONLY)                     │
│                                                                  │
│  • register_concern(db_filename, migrations[], tables[],         │
│                     service_id_column="service_id")              │
│  • run_migrations() — on startup, per concern DB                 │
│  • cascade_delete(service_id) — iterate concerns, DELETE rows    │
│  • connect(db_filename) → sqlite3.Connection (per-concern)       │
└──────────────┬───────────────────────────┬───────────────────────┘
               │                           │
    ┌──────────▼──────────┐      ┌─────────▼──────────────┐
    │ QbittorrentStore     │      │ MediaIndex             │
    │ (services/           │      │ (services/             │
    │  qbittorrent_store)  │      │  media_index_impl)     │
    │                      │      │                        │
    │ qbittorrent.db       │      │ media_index.sqlite     │
    │  └ qbittorrent_      │      │  └ media_items         │
    │    speed_samples     │      │    (+ service_id col)  │
    │    (service_id, ts,  │      │  └ index_metadata      │
    │     dl_speed,        │      │                        │
    │     up_speed)        │      │ bespoke: replace_items │
    │                      │      │ (scoped), query        │
    │ bespoke: append,     │      │ (scoped), status       │
    │ window, prune        │      │                        │
    └──────────────────────┘      └────────────────────────┘
               │
    ┌──────────▼──────────┐
    │ QbittorrentClient    │
    │ (clients/            │
    │  qbittorrent)        │
    │                      │
    │ login → cookie       │
    │ sync/maindata        │
    │ (totals + active +   │
    │  speeds)             │
    └──────────────────────┘
```

**Key constraints carried from the proposal:**

- Harness is lifecycle-only: migrations, service_id scoping, cascade-delete. No generic value table, no generic CRUD (D2).
- Per-concern DB files: `media_index.sqlite` stays put; new `qbittorrent.db` (D4).
- MediaIndex migration is sequenced after harness + qBit are proven (D3).
- qBit speed chart reuses the `PrometheusChartWidget` recharts renderer fed from an InService data path returning `{series}` (Q1).
- Totals = count of listed items, NOT transfer bytes (Q2).
- Active = state downloading|uploading (Q3). N instances (Q4). Username/password → cookie (Q5).

---

## 2. Backend design

### 2.1 `ServiceDataHarness` (`services/service_data.py`)

A lifecycle-only registry of storage concerns. Each concern declares its own DB
filename, ordered migrations, owned tables, and the column used for service
scoping.

```python
@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)
    tables: list[str]              # 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 migrations, service_id cascade-delete.
    Does NOT own: data operations (each store keeps bespoke append/window/query/etc.).
    """

    def __init__(self, base_dir: Path) -> None:
        self._base_dir = Path(base_dir)
        self._concerns: dict[str, StorageConcern] = {}

    def register(self, concern: StorageConcern) -> None:
        """Register a storage concern. Called at module import / startup."""
        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."""
        for concern in self._concerns.values():
            path = self.db_path(concern.concern_key)
            path.parent.mkdir(parents=True, exist_ok=True)
            with sqlite3.connect(path, timeout=30) as conn:
                conn.execute("PRAGMA journal_mode=WAL")
                conn.executescript(";".join(concern.migrations))

    def cascade_delete(self, service_id: str) -> None:
        """Delete all rows for a service_id across every concern's tables.

        Called from SettingsStore.delete_service after the service row is removed.
        """
        for concern in self._concerns.values():
            col = concern.service_id_column
            with sqlite3.connect(self.db_path(concern.concern_key), 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 + registration:**

```python
_HARNESS: ServiceDataHarness | None = None

def get_service_data_harness() -> ServiceDataHarness:
    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 (each store module calls register on import)
        _HARNESS.register(QBITTORRENT_CONCERN)
        _HARNESS.register(MEDIA_INDEX_CONCERN)
        _HARNESS.run_migrations()
    return _HARNESS
```

**Startup hook:** called from `main.py` lifespan alongside `get_settings_store().ensure_defaults()`. The harness is lazy-initialized on first access (like `SettingsStore`), so tests can override the base dir via env.

### 2.2 `QbittorrentSampleStore` (`services/qbittorrent_store.py`)

Speed-sample storage for the qBit speed widget. Registered as a concern with the
harness.

```python
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"],
)

MAX_SAMPLES = 120  # ~2 min at 1s poll, ~4 min at 2s poll


class QbittorrentSampleStore:
    """Bespoke speed-sample store for qBittorrent widgets."""

    def __init__(self, harness: ServiceDataHarness | None = None) -> None:
        self._harness = harness or get_service_data_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),
            )
            # Prune: keep only the most recent MAX_SAMPLES rows for this service
            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),
            )

    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]
```

### 2.3 `QbittorrentClient` (`clients/qbittorrent.py`)

Cookie-session HTTP client modeled on `JellyfinClient`'s session pattern.

```python
class QbittorrentClient:
    """Minimal qBittorrent Web API client (read-only: sync/maindata only)."""

    def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
        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."""
        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

    def _get(self, path: str, **params: Any) -> dict[str, Any]:
        """GET with auto 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:
            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 server_state + torrents dict.

        server_state contains: dl_info_speed (bytes/s), up_info_speed (bytes/s), etc.
        torrents is a dict of {hash: {name, state, progress, ...}}.
        """
        return self._get("/sync/maindata")
```

**Endpoints used (all from `/sync/maindata` — single call covers all three widgets):**

| Widget | Data extracted from `maindata()` |
|---|---|
| **totals** | `len(response["torrents"])` — count of all listed torrents |
| **active** | `filter(t for t in response["torrents"].values() if t["state"] in {"downloading","uploading"})` |
| **speed** | `response["server_state"]["dl_info_speed"]` + `["up_info_speed"]` (current instant speed, appended to store) |

**Note:** `/api/v2/transfer/info` is NOT needed (totals = item count per Q2, not transfer bytes).

### 2.4 Widget source adapter — `QbittorrentWidgetSource`

Lives in `widgets/sources.py`, implements `WidgetSource.fetch(service, widget_kind, config)`.
Resolves the client inline from `ServiceRecord` (like `PrometheusWidgetSource` does — no new
dependency-injection helper needed).

```python
class QbittorrentWidgetSource:
    """Fetch qBittorrent data for totals, active, and speed widgets."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        try:
            if service is None:
                return {"error": "qBittorrent widget is missing its service"}
            base_url = str(service.config.get("base_url") or "")
            username = str(service.secrets.get("username") or "")
            password = str(service.secrets.get("password") or "")
            timeout = int(service.config.get("timeout_seconds") or 10)
            if not base_url or not username or not password:
                return {"error": "qBittorrent service is missing base_url, username, or password"}

            client = QbittorrentClient(base_url, username, password, timeout)
            data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
            server_state = data.get("server_state", {})
            torrents = data.get("torrents", {})

            if widget_kind == "totals":
                # Q2: count of listed items, broken down by state
                by_state: dict[str, int] = {}
                for t in torrents.values():
                    state = str(t.get("state", "unknown"))
                    by_state[state] = by_state.get(state, 0) + 1
                return {"total": len(torrents), "by_state": by_state}

            if widget_kind == "active":
                # Q3: downloading or uploading only
                active = [
                    {"name": t.get("name"), "state": t.get("state"),
                     "size": t.get("size"), "progress": t.get("progress"),
                     "dl_speed": t.get("dlspeed"), "up_speed": t.get("upspeed")}
                    for t in torrents.values()
                    if str(t.get("state", "")) in {"downloading", "uploading"}
                ]
                return {"torrents": active}

            if widget_kind == "speed":
                # Q1: append sample + return window as {series} shape
                # matching PrometheusChartWidget's expected format
                dl = int(server_state.get("dl_info_speed", 0))
                up = int(server_state.get("up_info_speed", 0))
                ts = int(time.time())
                store = QbittorrentSampleStore()
                store.append(service.id, ts, dl, up)
                samples = store.window(service.id)
                series = [
                    {"label": "download",
                     "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
                    {"label": "upload",
                     "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
                ]
                return {"series": series}

            return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
        except asyncio.TimeoutError:
            return {"error": "qBittorrent data fetch timed out"}
        except Exception as exc:
            logger.exception("qbittorrent adapter failed")
            return {"error": f"qBittorrent fetch failed: {exc}"}
```

**Registered in `SERVICE_ADAPTERS`:**

```python
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
    "prometheus": PrometheusWidgetSource(),
    "qbittorrent": QbittorrentWidgetSource(),   # NEW
    "alertmanager": AlertmanagerWidgetSource(),
    "jellyfin": JellyfinWidgetSource(),
    "ssh_tasks": SshTaskWidgetSource(),
}
```

### 2.5 Integration registration — `integrations/qbittorrent.py`

Models config + secret schema on `prometheus.py`.

```python
class QbittorrentConfig(ServiceConfigBase):
    base_url: ServiceBaseUrl
    timeout_seconds: int = 10


# No per-widget config needed for any of the three kinds
# (all derive from the service connection).
class QbittorrentWidgetConfig(WidgetConfigBase):
    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),
    ],
)
```

**Registered in `integrations/registry.py`:**

```python
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT
```

### 2.6 Cascade-delete wiring

`SettingsStore.delete_service` gains a harness call after the service row is
deleted:

```python
def delete_service(self, service_id: str) -> None:
    self.init_schema()
    with self.connect() as conn:
        # existing widget cascade ...
        conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
    # NEW: cascade-delete harness-managed data
    try:
        from media_library_viewer_api.services.service_data import get_service_data_harness
        get_service_data_harness().cascade_delete(service_id)
    except Exception:
        logger.exception("harness cascade-delete failed for service %s", service_id)
```

The try/except guard prevents a harness failure from blocking service deletion
(data cleanup is best-effort; the service row is already gone).

---

## 3. MediaIndex migration design (load-bearing)

### 3.1 Schema migration: add `service_id` column

The `init_schema` method's `CREATE TABLE IF NOT EXISTS` gains the new column.
For existing databases, a migration adds it:

```sql
-- In MEDIA_INDEX_CONCERN.migrations (runs via harness on startup):
-- The init_schema already creates the table for new installs WITH service_id.
-- This migration handles existing DBs that lack the column.

-- media_index_impl.py init_schema: add service_id to the CREATE TABLE columns.
-- Harness migration (runs on existing DBs):
ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT '';
```

The `DEFAULT ''` backfills all existing rows to empty string (the legacy
sentinel — see §3.3 for how this is resolved).

### 3.2 `replace_items` — scoped delete + insert

```python
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
    self.init_schema()
    row_list = list(rows)
    # ... columns list gains "service_id" ...
    with self.connect() as conn:
        # SCOPED: only delete this service's rows
        conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
        conn.executemany(
            f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
            [[service_id] + [row.get(column) for column in columns_without_service_id] for row in row_list],
        )
        # ... metadata ...
    return len(row_list)
```

### 3.3 `query` — scoped filter

```python
def query(self, service_id: str = "", ...) -> tuple[list[dict[str, Any]], int]:
    # ... existing where clauses ...
    # Add service_id filter: if non-empty, scope; if empty (legacy), show all
    # (backward-compatibility for the period before multi-instance is wired in UI)
    if service_id:
        where.append("service_id = ?")
        params.append(service_id)
    # ... rest unchanged ...
```

**Backfill semantics:** existing rows get `service_id = ''` (empty string). When
`service_id` is empty string in the query, the filter is skipped, so the Media
page shows all items (backward-compatible behavior). When a specific Jellyfin
service triggers a rebuild, `replace_items(rows, service_id=that_service)` scopes
the delete + insert. New builds set the real service_id; legacy rows remain
visible until a rebuild replaces them.

### 3.4 `build_media_index` — thread service_id

`build_media_index` already receives no `service_id` today. Add it as a
parameter and pass it through to `replace_items`:

```python
def build_media_index(
    client, user_id, libraries, index=None, page_size=500,
    media_root="", fallback_prefix="",
    progress_callback=None, should_cancel=None,
    service_id: str = "",   # NEW
) -> int:
    # ... existing logic ...
    # Pass service_id to replace_items:
    processed_total = index.replace_items(normalized_rows, service_id=service_id)
```

### 3.5 Worker — already threads `service_id`, just pass it to `build_media_index`

The worker's `run_build(final_index_path, staging_index_path, service_id="")`
already receives `service_id` from argparse. The only change: pass it to
`build_media_index(..., service_id=service_id)`.

### 3.6 Media router — thread `service_id` into `query_media`

`query_media` already resolves `client: JellyfinClient = Depends(get_jellyfin_client)`.
The service_id is available via request query param (the dependency layer resolves
it from `?jellyfin_service_id=...`). Add it to the query call:

```python
@router.get("/query")
def query_media(
    ...,
    jellyfin_service_id: str | None = None,   # already available pattern
    index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
    ...
    rows, total = index.query(service_id=jellyfin_service_id or "", ...)
```

### 3.7 MediaIndex concern registration

```python
MEDIA_INDEX_CONCERN = StorageConcern(
    concern_key="media_index",
    db_filename="media_index.sqlite",  # SAME FILE, unchanged path
    migrations=[
        "ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
        # This ALTER is idempotent-safe: init_schema creates the table WITH
        # service_id for new installs; this migration adds it to existing DBs.
        # SQLite ALTER TABLE ADD COLUMN is a no-op if the column already exists
        # (we guard with a PRAGMA check in run_migrations, or catch the error).
    ],
    tables=["media_items"],
)
```

**IMPORTANT — ALTER TABLE idempotency:** SQLite raises an error if the column
already exists. The `run_migrations` method should catch this per-statement or
pre-check via `PRAGMA table_info`. Design choice: wrap each migration in a
try/except for "duplicate column name" errors:

```python
def run_migrations(self) -> None:
    for concern in self._concerns.values():
        path = self.db_path(concern.concern_key)
        path.parent.mkdir(parents=True, exist_ok=True)
        with sqlite3.connect(path, timeout=30) as conn:
            conn.execute("PRAGMA journal_mode=WAL")
            for stmt in concern.migrations:
                try:
                    conn.executescript(stmt)
                except sqlite3.OperationalError as exc:
                    if "duplicate column name" not in str(exc).lower():
                        raise
```

### 3.8 File location confirmation

`DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")` —
unchanged. The harness `base_dir` defaults to `Path(".cache/media_library_viewer")`
(the same parent). The `db_filename = "media_index.sqlite"` matches. No data move.

---

## 4. Frontend design

### 4.1 Three widget components

All three live in `frontend/src/widgets/`, modeled on existing patterns
(`AlertmanagerAlertsWidget`, `PrometheusChartWidget`, `MetricCard`).

**`QbittorrentTotalsWidget.tsx`** — MetricCard-style count tile:

```tsx
// Renders {total: number, by_state: {...}} from useWidgetData.
// Uses SectionCard + numeric display (like BackupsWidget / MetricCard).
// Shows total count prominently + state breakdown badges.
```

**`QbittorrentActiveTorrentsWidget.tsx`** — active torrents list:

```tsx
// Renders {torrents: [{name, state, size, progress, dl_speed, up_speed}]}.
// Uses SectionCard + a compact list/table (DataTable or manual Table rows).
// Shows name, state badge, progress bar, speeds. Max 10 rows with scroll.
```

**`QbittorrentSpeedWidget.tsx`** — speed chart reusing Change A's renderer:

```tsx
// Renders {series: [{label, points:[{t,v}]}]} — IDENTICAL to PrometheusChartWidget.
// Two options:
//   (a) Import PrometheusChartWidget directly and pass props (if its props accept
//       the series externally rather than via useWidgetData).
//   (b) Extract the recharts rendering into a shared <LineSeriesChart series={...} />
//       component that both PrometheusChartWidget and QbittorrentSpeedWidget use.
//
// RECOMMENDED: option (b) — extract a shared LineSeriesChart component (~40 lines)
// into frontend/src/components/LineSeriesChart.tsx. Both widgets call useWidgetData
// independently (different refresh intervals) but share the renderer.
```

**`LineSeriesChart.tsx`** (shared renderer extraction):

```tsx
// Extracts: mergeSeries, formatTime, CHART_COLORS, and the <ResponsiveContainer>
// + <LineChart> JSX from PrometheusChartWidget.
// Props: { series: ChartSeries[], height?: number }
// PrometheusChartWidget becomes a thin wrapper: useWidgetData → <LineSeriesChart series={data.data.series} />
// QbittorrentSpeedWidget: same pattern, different refresh interval (5s vs 60s).
```

This keeps the recharts rendering in ONE place (no duplication) while letting each
widget own its polling lifecycle.

### 4.2 Frontend registry binding (`integrations/registry.ts`)

```typescript
qbittorrent: {
    serviceType: "qbittorrent",
    name: "qBittorrent",
    description: "Torrent client activity, speeds, and item counts.",
    widgets: [
        { kind: "totals", name: "Totals", description: "...",
          refreshIntervalMs: 30_000, defaultConfig: {},
          configSchema: { type: "object", properties: {}, required: [] },
          component: QbittorrentTotalsWidget },
        { kind: "active", name: "Active torrents", description: "...",
          refreshIntervalMs: 15_000, defaultConfig: {},
          configSchema: { type: "object", properties: {}, required: [] },
          component: QbittorrentActiveTorrentsWidget },
        { kind: "speed", name: "Speed chart", description: "...",
          refreshIntervalMs: 5_000, defaultConfig: {},
          configSchema: { type: "object", properties: {}, required: [] },
          component: QbittorrentSpeedWidget },
    ],
},
```

### 4.3 Types

No new TypeScript types needed for widget payloads — data flows through the
existing `WidgetDataResponse` + `useWidgetData` polling. The series shape
(`{series:[{label,points:[{t,v}]}]}`) is already used by `PrometheusChartWidget`.

---

## 5. Tests

### 5.1 Backend tests

| Test file | Coverage |
|---|---|
| `backend/tests/test_service_data.py` (NEW) | Harness: register, run_migrations (creates tables), cascade_delete (removes rows by service_id), migration idempotency (ALTER doesn't crash on re-run) |
| `backend/tests/test_qbittorrent_store.py` (NEW) | Store: append + prune (MAX_SAMPLES cap), window (returns samples in order), service_id isolation (two services don't cross-contaminate) |
| `backend/tests/test_qbittorrent_client.py` (NEW) | Client: login flow (POST /auth/login → Ok.), cookie reuse, 403 → re-login, maindata parsing, timeout handling |
| `backend/tests/test_widgets.py` (extend) | `QbittorrentWidgetSource`: totals (counts all torrents), active (filters state), speed (appends sample + returns {series}), missing-service error, timeout error |

### 5.2 Frontend tests

| Test file | Coverage |
|---|---|
| `QbittorrentTotalsWidget.test.tsx` (NEW) | Loading skeleton, error alert, rendered count + state badges |
| `QbittorrentActiveTorrentsWidget.test.tsx` (NEW) | Loading, error, rendered torrent rows |
| `QbittorrentSpeedWidget.test.tsx` (NEW) | Loading, error, rendered chart (series present) |
| `LineSeriesChart.test.tsx` (NEW) | Renders lines from series data, empty state |

### 5.3 Existing MediaIndex tests must stay green

`backend/tests/test_media_index.py` exercises `replace_items`, `query`, `status`.
After migration, these call with the new `service_id=""` default (backward-compatible).
The tests pass unchanged because empty-string service_id shows all rows.

---

## 6. Slice plan (for tasks.md)

Four slices, each ≤400 changed lines, each leaving `pytest` + `npm run build` +
`npm run lint` green. Slices 1–2 prove the harness; Slice 3 migrates MediaIndex;
Slice 4 wires cascade-delete end-to-end.

### Slice 1: Harness + QbittorrentSampleStore + QbittorrentClient + integration (~280–350 lines)

**Files:**

- `backend/src/media_library_viewer_api/services/service_data.py` (NEW — harness + StorageConcern)
- `backend/src/media_library_viewer_api/services/qbittorrent_store.py` (NEW — store + concern)
- `backend/src/media_library_viewer_api/clients/qbittorrent.py` (NEW — client)
- `backend/src/media_library_viewer_api/integrations/qbittorrent.py` (NEW — definition)
- `backend/src/media_library_viewer_api/integrations/registry.py` (MODIFY — add qbittorrent)
- `backend/src/media_library_viewer_api/main.py` (MODIFY — call harness init in lifespan)
- `backend/tests/test_service_data.py` (NEW)
- `backend/tests/test_qbittorrent_store.py` (NEW)
- `backend/tests/test_qbittorrent_client.py` (NEW)

**Exit gate:** harness creates tables + runs migrations + cascade_delete works in
tests. qBit client login/maindata tested with mocked HTTP. No frontend changes yet.

### Slice 2: Widget adapter + frontend widgets + registry binding (~320–400 lines)

**Files:**

- `backend/src/media_library_viewer_api/widgets/sources.py` (MODIFY — add QbittorrentWidgetSource + SERVICE_ADAPTERS entry)
- `backend/tests/test_widgets.py` (EXTEND — qBit adapter tests)
- `frontend/src/components/LineSeriesChart.tsx` (NEW — shared renderer extracted from PrometheusChartWidget)
- `frontend/src/widgets/PrometheusChartWidget.tsx` (MODIFY — use LineSeriesChart)
- `frontend/src/widgets/QbittorrentTotalsWidget.tsx` (NEW)
- `frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx` (NEW)
- `frontend/src/widgets/QbittorrentSpeedWidget.tsx` (NEW)
- `frontend/src/widgets/index.ts` (MODIFY — barrel exports)
- `frontend/src/integrations/registry.ts` (MODIFY — add qbittorrent binding)
- `frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx` (NEW)
- `frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx` (NEW)
- `frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx` (NEW)
- `frontend/src/components/__tests__/LineSeriesChart.test.tsx` (NEW)

**Exit gate:** qBit widgets render loading/error/data states; speed widget shows
a recharts line chart from {series}; LineSeriesChart is shared with PrometheusChartWidget.

### Slice 3: MediaIndex migration onto harness (~200–280 lines)

**Files:**

- `backend/src/media_library_viewer_api/services/service_data.py` (MODIFY — register MEDIA_INDEX_CONCERN)
- `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY — add service_id to schema, replace_items, query)
- `backend/src/media_library_viewer_api/services/media_index.py` (re-export unchanged)
- `backend/src/media_library_viewer_api/workers/media_index_worker.py` (MODIFY — pass service_id to build_media_index)
- `backend/src/media_library_viewer_api/routers/media.py` (MODIFY — thread jellyfin_service_id into query_media)
- `backend/tests/test_media_index.py` (EXTEND — service_id-scoped replace_items + query)

**Exit gate:** all existing MediaIndex tests pass unchanged (empty-string default);
new tests verify scoped delete/insert/query. Media page works identically.

### Slice 4: Cascade-delete end-to-end + integration test (~80–120 lines)

**Files:**

- `backend/src/media_library_viewer_api/services/settings_store.py` (MODIFY — call harness.cascade_delete in delete_service)
- `backend/tests/test_services.py` (EXTEND — verify cascade-delete removes qBit samples when service is deleted)

**Exit gate:** deleting a qBittorrent service removes its speed samples; deleting
a Jellyfin service removes its media items.

---

## 7. Key design decisions summary

| # | Decision | Rationale |
|---|---|---|
| D1 | Harness is lifecycle-only (migrations, service_id, cascade) | n=2 justifies abstraction, but data-ops differ wildly (append/window vs replace_all/query). Generalize only the shared lifecycle. |
| D2 | Per-concern DB files via harness `base_dir` + `db_filename` | media_index.sqlite stays put (no data move); qbittorrent.db is new. Separate writers. |
| D3 | `run_migrations` catches "duplicate column name" per-statement | SQLite ALTER TABLE ADD COLUMN is not idempotent; migrations must not crash on re-run. |
| D4 | Speed widget extracts a shared `LineSeriesChart` component | Both PrometheusChartWidget and QbittorrentSpeedWidget need the same recharts renderer; extract it once (DRY) rather than duplicating or tightly coupling. |
| D5 | qBit adapter resolves client inline from ServiceRecord (like PrometheusWidgetSource) | No new dependency-injection helper; the adapter gets config+secrets from the service record. |
| D6 | MediaIndex backfill uses `service_id = ''` (empty string) sentinel | Backward-compatible: existing tests + Media page work unchanged; empty-string queries skip the filter (show all). Real service_ids overwrite on next rebuild. |
| D7 | `replace_items` deletes `WHERE service_id = ?` instead of all | Fixes the existing global-clear bug (building for one Jellyfin wipes others). |
| D8 | Cascade-delete is best-effort (try/except) in delete_service | Harness failure must not block service deletion; data cleanup is non-critical. |
| D9 | Speed sample timestamps multiplied by 1000 for frontend | PrometheusChartWidget expects `t` in milliseconds (JS epoch); SQLite stores Unix seconds. |
| D10 | MAX_SAMPLES = 120 cap with per-append prune | ~2 min history at 1s poll; bounded DB growth; single-statement prune. |

---

## 8. Risks and mitigations

| Risk | Mitigation |
|---|---|
| **MediaIndex migration breaks existing tests.** | `service_id` defaults to `""`; empty-string queries skip the filter → all rows visible. Existing tests pass unchanged. |
| **ALTER TABLE fails on fresh installs** where init_schema already created the column. | `run_migrations` catches "duplicate column name" errors per-statement. |
| **Harness lazy-init hides migration failures.** | `run_migrations` runs on first access (startup lifespan); failures raise (not swallowed) except the expected duplicate-column case. |
| **Speed widget poll interval (5s) too aggressive.** | 5s is the default; configurable via widget config if needed. MAX_SAMPLES cap bounds storage. |
| **qBit cookie expiry between polls.** | Client re-logins on 403 transparently; persistent auth failure surfaces as widget error state. |
| **LineSeriesChart extraction breaks PrometheusChartWidget.** | Slice 2 includes tests for both; extraction is mechanical (move JSX + helpers, pass series as prop). |
| **Review budget (>400 lines).** | Four slices, each ≤400 lines. Slices 1–2 are independently shippable (qBit works without MediaIndex migration). |

---

## 9. Data flow diagrams

### 9.1 Speed widget data flow (InService path)

```
Dashboard poll (useWidgetData, 5s)
  └► GET /api/widgets/instances/{id}/data
       └► QbittorrentWidgetSource.fetch(service, "speed", {})
            ├► QbittorrentClient.maindata()
            │     └► /api/v2/sync/maindata → {server_state:{dl_info_speed, up_info_speed}}
            ├► QbittorrentSampleStore.append(service.id, ts, dl, up)
            │     └► INSERT + prune (keep 120)
            ├► QbittorrentSampleStore.window(service.id)
            │     └► SELECT ts, dl_speed, up_speed → [{ts, dl_speed, up_speed}]
            └► return {series: [{label:"download", points:[{t,v}]}, {label:"upload", points:[{t,v}]}]}
                 └► Frontend: QbittorrentSpeedWidget → <LineSeriesChart series={...} />
```

### 9.2 Cascade-delete data flow

```
DELETE /api/services/instances/{id}
  └► SettingsStore.delete_service(id)
       ├► DELETE FROM dashboard_widgets WHERE service_id = ?
       ├► DELETE FROM services WHERE id = ?
       └► ServiceDataHarness.cascade_delete(id)
            ├► DELETE FROM qbittorrent_speed_samples WHERE service_id = ?
            └► DELETE FROM media_items WHERE service_id = ?
```
