From c9404f07944178e34f198eef69dd358aee8283aa Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 9 Jul 2026 09:15:32 +0000 Subject: [PATCH] spec(service-storage-harness): add spec (28 requirements SS-101..128) Covers harness lifecycle, QbittorrentSampleStore, QbittorrentClient, 3 widget kinds (totals=item count, active=DL/UL, speed=LineSeriesChart reuse), MediaIndex migration (+service_id, scoped replace_items bug fix, backward-compat query), cascade-delete wiring, test/build greenness. --- .../changes/service-storage-harness/spec.md | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 openspec/changes/service-storage-harness/spec.md diff --git a/openspec/changes/service-storage-harness/spec.md b/openspec/changes/service-storage-harness/spec.md new file mode 100644 index 0000000..8e9d12a --- /dev/null +++ b/openspec/changes/service-storage-harness/spec.md @@ -0,0 +1,155 @@ +# SDD Spec: Service Storage Harness (qBittorrent widgets + MediaIndex migration) + +**Change:** `service-storage-harness` +**Phase:** spec +**Date:** 2026-07-09 + +This spec defines the acceptance requirements for the change, derived from the reconciled `proposal.md` and `design.md`. Requirements are testable. + +## Requirement categories + +1. ServiceDataHarness (lifecycle layer) +2. QbittorrentSampleStore +3. QbittorrentClient +4. qBittorrent widget source adapter +5. qBittorrent frontend widgets +6. MediaIndex migration onto harness +7. Cascade-delete wiring +8. Test + build greenness + +--- + +## 1. ServiceDataHarness (lifecycle layer) + +### SS-101 — Harness is lifecycle-only + +A `ServiceDataHarness` class exists in `backend/src/media_library_viewer_api/services/service_data.py` that owns ONLY lifecycle concerns: per-concern DB provisioning, per-integration migrations, `service_id` cascade-delete. It MUST NOT provide generic data operations (no generic value table, no generic CRUD). + +### SS-102 — Concern registration + +An integration/concern registers via a dataclass carrying: DB filename, ordered migration SQL list, owned-tables list, and `service_id` column name. The harness stores registered concerns. + +### SS-103 — Idempotent migrations + +`run_migrations` runs each concern's migration statements and MUST be idempotent — specifically, re-running `ALTER TABLE ... ADD COLUMN` on an already-migrated DB MUST NOT raise (the harness catches "duplicate column name" per-statement). + +### SS-104 — Cascade-delete across concerns + +`cascade_delete(service_id)` iterates every registered concern and, for each owned table, executes `DELETE FROM WHERE = ?`. It MUST cover every registered concern (qBittorrent samples + media items after this change). + +## 2. QbittorrentSampleStore + +### SS-105 — Schema + +`QbittorrentSampleStore` owns a `qbittorrent_speed_samples` table with columns `(service_id, ts, dl_speed, up_speed)` and an index on `(service_id, ts)`, in a dedicated `qbittorrent.db` file (per-concern topology). + +### SS-106 — append/window/prune operations + +The store exposes `append(service_id, ts, dl_speed, up_speed)`, `window(service_id, since_ts)` returning ordered rows, and prunes per-append to `MAX_SAMPLES = 120`. + +### SS-107 — Registered as a harness concern + +The qBittorrent sample store is registered with the harness so its table participates in cascade-delete (SS-104). + +## 3. QbittorrentClient + +### SS-108 — Cookie login + +`QbittorrentClient` authenticates via `POST /api/v2/auth/login` with username/password, stores the resulting cookie, and reuses it for subsequent requests. Credentials are resolved from the `ServiceRecord` secrets (Fernet-encrypted at rest). + +### SS-109 — 403 re-login + +On HTTP 403 the client MUST transparently re-login once and retry the request. + +### SS-110 — maindata fetch + +The client exposes `maindata()` calling `/api/v2/sync/maindata` and returning its dict. Errors (timeout, connection, non-2xx) propagate as exceptions for the adapter to catch. + +## 4. qBittorrent widget source adapter + +### SS-111 — Three widget kinds dispatched + +`QbittorrentWidgetSource.fetch(service, widget_kind, config)` dispatches on `widget_kind` ∈ {`totals`, `active`, `speed`}, resolving the client inline from `ServiceRecord` (same pattern as `PrometheusWidgetSource`). + +### SS-112 — totals = item count + +`totals` returns the count of currently-listed torrents from `maindata()` as `{total: int}`. It is NOT cumulative transfer bytes. + +### SS-113 — active = downloading/uploading filter + +`active` returns the subset of torrents whose `state` is `downloading` or `uploading` as `{torrents: [...]}`. + +### SS-114 — speed appends sample + returns series + +`speed` reads the current dl/up speeds from `maindata()`, appends a sample via `QbittorrentSampleStore.append`, and returns `{series: [{label: "download", points: [...]}, {label: "upload", points: [...]}]}` from `.window()` — the exact shape `LineSeriesChart` consumes (timestamps in JS milliseconds). + +### SS-115 — Errors degrade gracefully + +Adapter errors (auth failure, timeout, connection) return `{error: str}` and MUST NOT raise. + +## 5. qBittorrent frontend widgets + +### SS-116 — Three widget components + +`QbittorrentTotalsWidget`, `QbittorrentActiveTorrentsWidget`, `QbittorrentSpeedWidget` exist under `frontend/src/widgets/` and are bound to the `qbittorrent` service binding in `integrations/registry.ts` with their respective kinds. + +### SS-117 — Speed widget reuses shared renderer + +`QbittorrentSpeedWidget` renders via the shared `LineSeriesChart` component (extracted from `PrometheusChartWidget`). No new charting code or charting dependency. + +### SS-118 — LineSeriesChart extraction is non-regressive + +The extraction of the recharts body into `frontend/src/components/LineSeriesChart.tsx` MUST leave `PrometheusChartWidget`'s behavior and tests green; `PrometheusChartWidget` becomes a thin wrapper. + +## 6. MediaIndex migration onto harness + +### SS-119 — service_id column added + +`media_items` gains a `service_id TEXT NOT NULL DEFAULT ''` column via an idempotent harness migration. The `media_index.db` file location is UNCHANGED. + +### SS-120 — Existing rows backfill + +All pre-existing `media_items` rows receive `service_id = ''` (via the column DEFAULT), preserving their visibility. + +### SS-121 — replace_items is scoped (bug fix) + +`replace_items(service_id=...)` deletes only `WHERE service_id = ?` (not a global `DELETE FROM media_items`). This FIXES the latent global-clear bug where rebuilding for one Jellyfin instance wiped another's rows. A regression test MUST prove service A's rows survive service B's rebuild. + +### SS-122 — query is backward-compatible + +`query(service_id="")` returns all rows (no filter); `query(service_id="X")` scopes to service X. Existing tests that pass no `service_id` MUST continue to pass unchanged. + +### SS-123 — Worker threads service_id into rows + +The media index worker (which already receives `--service-id`) passes it into `replace_items` so newly-built rows are stamped with the real service_id. + +### SS-124 — Registered as a harness concern + +MediaIndex registers `media_items` as a harness-owned table so it participates in cascade-delete. + +## 7. Cascade-delete wiring + +### SS-125 — delete_service triggers harness cascade + +`settings_store.delete_service` calls `ServiceDataHarness.cascade_delete(service_id)` after its existing cleanup, in a best-effort try/except (a cascade failure MUST NOT crash the service deletion; it logs and continues). + +### SS-126 — End-to-end cascade across both concerns + +Deleting a service removes both its qBittorrent samples AND its media rows. An integration test MUST prove this across both concerns, and MUST prove rows of OTHER services are preserved. + +## 8. Test + build greenness + +### SS-127 — Backend tests + lint green + +`PYTHONPATH=src python3 -m pytest -q` and `PYTHONPATH=src python3 -m ruff check src tests` from `backend/` MUST pass, including new tests for: harness lifecycle, qBit store, qBit client (login/403/maindata), qBit widget adapter (3 branches), MediaIndex scoped replace_items regression, and cascade-delete integration. + +### SS-128 — Frontend build + lint green + +`npm run build` and `npm run lint` from `frontend/` MUST pass (0 errors). New widget tests cover loading/error/rendered states; PrometheusChartWidget tests stay green (SS-118). + +--- + +## Notes for downstream phases + +- **Verify** should confirm SS-101..SS-128 against source; the load-bearing items are SS-121 (scoped replace_items bug fix), SS-122/SS-123 (MediaIndex backward-compat), and SS-118 (non-regressive extraction). +- **Sync + archive** follow the Change A pattern (delta into a new canonical domain `service-storage`, then archive to `openspec/changes/archive/2026-07-09-service-storage-harness/`).