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.
7.7 KiB
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
- ServiceDataHarness (lifecycle layer)
- QbittorrentSampleStore
- QbittorrentClient
- qBittorrent widget source adapter
- qBittorrent frontend widgets
- MediaIndex migration onto harness
- Cascade-delete wiring
- 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 <table> WHERE <service_id_column> = ?. 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 toopenspec/changes/archive/2026-07-09-service-storage-harness/).