spec(service-storage-harness): sync into canonical service-storage domain
New canonical openspec/specs/service-storage/spec.md (28 reqs SS-101..128). Change-side delta + sync-report. web-ui + prometheus-charting canonicals untouched.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
# Service Storage — Delta (`service-storage-harness`)
|
||||
|
||||
> Change: `service-storage-harness` · Domain: `service-storage` · Phase: **spec** (reconciled during `sdd-sync`).
|
||||
> Distilled verbatim from the verified flat `spec.md` (28 requirements, SS-101 … SS-128) of change
|
||||
> `service-storage-harness`, cross-referenced against `design.md` and `verify-report.md`. Captures
|
||||
> the **durable, post-change end-state contracts** for the lifecycle-only `ServiceDataHarness`, the
|
||||
> qBittorrent store/client/widget stack built on it, the MediaIndex migration onto the harness, and
|
||||
> the cross-concern cascade-delete wiring.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
> The canonical `openspec/specs/service-storage/spec.md` did not exist before this change. All
|
||||
> requirements below are therefore **ADDED** to a new `service-storage` domain; `sdd-sync` copies
|
||||
> them into the canonical spec (native helper rule: when the canonical spec does not exist, the
|
||||
> change spec becomes the new canonical spec).
|
||||
>
|
||||
> Requirement IDs (SS-101 … SS-128) and body text are preserved **exactly** from the verified flat
|
||||
> `spec.md`. Requirements are grouped logically and listed in the following group order:
|
||||
>
|
||||
> - **ServiceDataHarness (lifecycle layer)** — SS-101 … SS-104
|
||||
> - **QbittorrentSampleStore** — SS-105 … SS-107
|
||||
> - **QbittorrentClient** — SS-108 … SS-110
|
||||
> - **qBittorrent widget source adapter** — SS-111 … SS-115
|
||||
> - **qBittorrent frontend widgets** — SS-116 … SS-118
|
||||
> - **MediaIndex migration onto harness** — SS-119 … SS-124
|
||||
> - **Cascade-delete wiring** — SS-125 … SS-126
|
||||
> - **Test and build greenness** — SS-127 … SS-128
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: SS-104 — Cascade-delete across concerns
|
||||
|
||||
`cascade_delete(service_id)` iterates every registered concern and, for each owned table, executes `DELETE FROM <table> WHERE <service_id_column> = ?`. It MUST cover every registered concern (qBittorrent samples + media items after this change).
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: 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`.
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: SS-109 — 403 re-login
|
||||
|
||||
On HTTP 403 the client MUST transparently re-login once and retry the request.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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`).
|
||||
|
||||
### Requirement: SS-112 — totals = item count
|
||||
|
||||
`totals` returns the count of currently-listed torrents from `maindata()` as `{total: int}`. It is NOT cumulative transfer bytes.
|
||||
|
||||
### Requirement: SS-113 — active = downloading/uploading filter
|
||||
|
||||
`active` returns the subset of torrents whose `state` is `downloading` or `uploading` as `{torrents: [...]}`.
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: SS-115 — Errors degrade gracefully
|
||||
|
||||
Adapter errors (auth failure, timeout, connection) return `{error: str}` and MUST NOT raise.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: SS-117 — Speed widget reuses shared renderer
|
||||
|
||||
`QbittorrentSpeedWidget` renders via the shared `LineSeriesChart` component (extracted from `PrometheusChartWidget`). No new charting code or charting dependency.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: SS-120 — Existing rows backfill
|
||||
|
||||
All pre-existing `media_items` rows receive `service_id = ''` (via the column DEFAULT), preserving their visibility.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: SS-124 — Registered as a harness concern
|
||||
|
||||
MediaIndex registers `media_items` as a harness-owned table so it participates in cascade-delete.
|
||||
|
||||
### Requirement: 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).
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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.
|
||||
|
||||
### Requirement: 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).
|
||||
Reference in New Issue
Block a user