Files
manage/openspec/changes/archive/2026-07-09-service-storage-harness/specs/service-storage/spec.md
T
Developer 5cb5e79032 chore(service-storage-harness): archive verified+synced change
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.
2026-07-09 09:47:05 +00:00

8.4 KiB

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).

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).