Files
manage/openspec/specs/service-storage/spec.md
T
Developer c9201a004c 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.
2026-07-09 09:37:22 +00:00

8.5 KiB

Service Storage

Domain: service-storage · Canonical specification. Synced from change service-storage-harness.

This is the merged end-state of standing up a lifecycle-only per-service data layer and migrating the qBittorrent integration and the MediaIndex onto it. It captures the durable, post-change contracts for the ServiceDataHarness lifecycle layer, the qBittorrent store/client/widget stack, the MediaIndex migration, and cross-concern cascade-delete — not the per-slice delivery strategy (which remains on record in the change's spec.md / tasks.md under openspec/changes/service-storage-harness/).

Purpose

Define WHAT must be true of Manage's per-service data lifecycle and storage layer after the change: a lifecycle-only ServiceDataHarness provisions per-concern databases, runs idempotent per-integration migrations, and cascades service_id deletes across all owned tables — without offering any generic value table or CRUD. The qBittorrent integration (cookie-auth client + time-windowed sample store + three-branch widget adapter + three frontend widgets) is the first concern built on the harness. The MediaIndex migrates onto the harness as the second concern, gaining a service_id column and a scoped replace_items that fixes the latent global-clear bug where rebuilding one Jellyfin instance wiped another's rows. The shared LineSeriesChart renderer (extracted non-regressively from PrometheusChartWidget) backs the qBittorrent speed widget. Deleting a service cascades across both concerns while preserving other services' rows. This spec is acceptance-focused and verifiable; it deliberately does not prescribe implementation.

Requirements

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