Move to openspec/changes/archive/2026-07-09-service-storage-harness/ (history preserved via rename detection). 9 artifacts: proposal/spec/design/ tasks/apply-progress/verify-report/sync-report/archive-report + delta spec. Canonical openspec/specs/service-storage/ remains. Native status engine discrepancy (ambiguous change selection) disregarded per parent verification.
8.4 KiB
Service Storage — Delta (service-storage-harness)
Change:
service-storage-harness· Domain:service-storage· Phase: spec (reconciled duringsdd-sync). Distilled verbatim from the verified flatspec.md(28 requirements, SS-101 … SS-128) of changeservice-storage-harness, cross-referenced againstdesign.mdandverify-report.md. Captures the durable, post-change end-state contracts for the lifecycle-onlyServiceDataHarness, 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.mddid not exist before this change. All requirements below are therefore ADDED to a newservice-storagedomain;sdd-synccopies 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).