Files
manage/openspec/changes/service-storage-harness/spec.md
T
Developer c9404f0794 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.
2026-07-09 09:15:32 +00:00

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

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

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