Files
manage/openspec/changes/service-storage-harness/tasks.md
T
Developer 9a251db23c spec(service-storage-harness): add tasks (4 slices, each <=400 lines)
S1 harness+store+client+integration; S2 widget adapter+3 FE widgets+
LineSeriesChart extract; S3 MediaIndex +service_id migration (fixes latent
global-clear bug); S4 cascade-delete wiring. Each slice leaves pytest/npm
build/npm lint green. Risk flags on LineSeriesChart extract + MediaIndex migration.
2026-07-09 08:02:31 +00:00

23 KiB
Raw Blame History

SDD Tasks: Service Storage Harness (qBittorrent widgets + MediaIndex migration)

Change: service-storage-harness Phase: tasks Date: 2026-07-09

Review Workload Forecast

Field Value
Estimated changed lines ~1,1801,380 (sum of four implementation slices)
400-line budget risk High
Chained PRs recommended Yes
Suggested split PR 1: Harness + QbittorrentSampleStore + client + integration → PR 2: Widget adapter + LineSeriesChart extract + 3 FE widgets → PR 3: MediaIndex migration → PR 4: Cascade-delete wiring
Delivery strategy auto-chain
Chain strategy stacked-to-main
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High

Slice ordering rationale: Slices 12 prove the harness and deliver all qBittorrent functionality independently of the MediaIndex migration. Slice 3 migrates the load-bearing MediaIndex feature. Slice 4 wires cascade-delete end-to-end. This ordering ensures the two biggest risks (new abstraction × load-bearing refactor) never fire in the same slice — the harness shape is settled in code before MediaIndex touches it.


Slice 1: ServiceDataHarness + QbittorrentSampleStore + QbittorrentClient + integration registration

Goal: Build the lifecycle-only storage harness, prove it with the qBittorrent speed-sample store, add the qBittorrent HTTP client and integration registration. No widgets or frontend yet — this slice is backend-only and proves the harness contract with a real consumer.

  • 1.1 Create ServiceDataHarness lifecycle module

    • Files: backend/src/media_library_viewer_api/services/service_data.py (NEW)
    • Lines: ~100
    • Dependencies: none
    • Details: StorageConcern dataclass (concern_key, db_filename, migrations: list[str], tables: list[str], service_id_column="service_id"). ServiceDataHarness class with register(concern), db_path(concern_key), connect(concern_key) (WAL + busy_timeout 30s), run_migrations() (per-concern, per-statement try/except for "duplicate column name"), cascade_delete(service_id) (iterate concerns, PRAGMA-check column exists, DELETE WHERE col = ?). Module singleton get_service_data_harness() that lazy-inits with base_dir = BACKEND_CACHE_DIR or ".cache/media_library_viewer" and calls run_migrations(). NO generic value table, NO generic CRUD.
  • 1.2 Create QbittorrentSampleStore with concern registration

    • Files: backend/src/media_library_viewer_api/services/qbittorrent_store.py (NEW)
    • Lines: ~80
    • Dependencies: 1.1
    • Details: Define QBITTORRENT_CONCERN (db_filename="qbittorrent.db", migration creates qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed) + index on (service_id, ts), tables=["qbittorrent_speed_samples"]). MAX_SAMPLES = 120. QbittorrentSampleStore class: append(service_id, ts, dl_speed, up_speed) (INSERT + prune keeping most recent MAX_SAMPLES for this service_id), window(service_id, since_ts=None) (SELECT ordered ASC). Register QBITTORRENT_CONCERN with harness on import.
  • 1.3 Create QbittorrentClient HTTP client

    • Files: backend/src/media_library_viewer_api/clients/qbittorrent.py (NEW)
    • Lines: ~90
    • Dependencies: none
    • Details: QbittorrentClient(base_url, username, password, timeout=10) modeled on JellyfinClient's session pattern. _login() POSTs to /auth/login with Referer header, expects "Ok." response, stores SID cookie. _get(path, **params) auto-logins on first call, re-logins on 403. maindata() calls /sync/maindata returning {server_state: {...}, torrents: {hash: {...}}}. Base URL normalization: append /api/v2 if not present. Timeout + requests.RequestException handling consistent with existing clients.
  • 1.4 Create qBittorrent integration definition

    • Files: backend/src/media_library_viewer_api/integrations/qbittorrent.py (NEW)
    • Lines: ~55
    • Dependencies: 1.1 (for ServiceConfigBase, WidgetConfigBase, SecretField, ServiceDefinition, widget_kind from base.py)
    • Details: QbittorrentConfig(ServiceConfigBase) with base_url: ServiceBaseUrl, timeout_seconds: int = 10. QbittorrentWidgetConfig(WidgetConfigBase) empty (all three widget kinds derive from service connection). DEFINITION = ServiceDefinition(service_type="qbittorrent", ...) with secret_fields=[username (required), password (required)] and three widget_kinds: totals (30s refresh), active (15s), speed (5s). Model on integrations/prometheus.py.
  • 1.5 Register qBittorrent in backend integration registry

    • Files: backend/src/media_library_viewer_api/integrations/registry.py (MODIFY)
    • Lines: ~5
    • Dependencies: 1.4
    • Details: Import DEFINITION as QBITTORRENT from integrations/qbittorrent.py; add SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT. Verify list_service_types() now includes qbittorrent.
  • 1.6 Initialize harness in application lifespan

    • Files: backend/src/media_library_viewer_api/main.py (MODIFY, lifespan function ~line 41)
    • Lines: ~5
    • Dependencies: 1.1, 1.2
    • Details: In lifespan() after get_settings_store().ensure_defaults(), add a try/except block calling get_service_data_harness() (triggers lazy init + migration run). Log on failure (same pattern as the settings seed block at lines 5154). Import is local inside lifespan to avoid circular import risk.
  • 1.7 Create backend tests for harness + store

    • Files: backend/tests/test_service_data.py (NEW)
    • Lines: ~110
    • Dependencies: 1.1, 1.2
    • Details: Test ServiceDataHarness: register concern → run_migrations() creates table; cascade_delete(service_id) removes rows by service_id but leaves other services' rows; migration idempotency (re-run doesn't crash). Test QbittorrentSampleStore: append + prune (append 130 samples, assert only 120 remain), window returns ordered samples, two services don't cross-contaminate. Use tmp_path for base_dir isolation.
  • 1.8 Create backend tests for qBittorrent client

    • Files: backend/tests/test_qbittorrent_client.py (NEW)
    • Lines: ~85
    • Dependencies: 1.3
    • Details: Test login flow (mock requests.Session.post"Ok." + cookie), cookie reuse on subsequent calls, 403 → re-login flow (first GET returns 403, second returns 200 after re-login), maindata() parsing, base URL normalization (appends /api/v2), login failure (response ≠ "Ok." raises RuntimeError), timeout handling. Use unittest.mock.patch on the session.
  • 1.9 Verify Slice 1 (backend only)

    • Run: cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -v
    • Verify: all new tests pass; existing tests unaffected.

Slice 1 total: ~310390 changed lines. Backend-only; no frontend changes.


Slice 2: Widget source adapter + LineSeriesChart extraction + frontend widgets + registry binding

Goal: Wire qBittorrent data into the widget system, extract a shared chart renderer, and build the three frontend widgets. This slice touches PrometheusChartWidget (extraction) — its existing tests MUST stay green.

  • 2.1 Add QbittorrentWidgetSource adapter to widgets/sources.py

    • Files: backend/src/media_library_viewer_api/widgets/sources.py (MODIFY)
    • Lines: ~75
    • Dependencies: Slice 1 (1.3, 1.2)
    • Details: Add QbittorrentWidgetSource implementing WidgetSource.fetch(service, widget_kind, config). Resolves client inline from ServiceRecord config+secrets (like PrometheusWidgetSource). Three branches:
      • totals: len(torrents) + by_state dict breakdown. Returns {total, by_state}.
      • active: filter state in {"downloading","uploading"}. Returns {torrents: [{name, state, size, progress, dl_speed, up_speed}]}.
      • speed: append sample to QbittorrentSampleStore(service.id, ts, dl, up), read window(service.id), return {series: [{label:"download", points:[{t: ts*1000, v: dl}]}, {label:"upload", ...}]} (timestamps ×1000 for JS epoch). All branches wrap in try/except → {"error": ...}. Add "qbittorrent": QbittorrentWidgetSource() to SERVICE_ADAPTERS.
  • 2.2 Add backend tests for qBittorrent widget adapter

    • Files: backend/tests/test_widgets.py (EXTEND)
    • Lines: ~90
    • Dependencies: 2.1
    • Details: Test QbittorrentWidgetSource with mocked QbittorrentClient.maindata(): totals counts all torrents + breaks down by state; active filters to DL/UL only (assert queued/stalled excluded); speed appends sample and returns {series} shape with two labeled series; missing service → {"error": ...}; timeout → {"error": ...}. Use tmp_path harness override for the store.
  • 2.3 Extract shared LineSeriesChart component from PrometheusChartWidget

    • Files: frontend/src/components/LineSeriesChart.tsx (NEW), frontend/src/widgets/PrometheusChartWidget.tsx (MODIFY)
    • Lines: ~75 (new file) + ~20 (PrometheusChartWidget thin wrapper)
    • Dependencies: none (refactor of existing code)
    • Details: RISK ITEM (a). Extract mergeSeries, formatTime, CHART_COLORS, ChartSeries/SeriesPoint types, and the <ResponsiveContainer>+<LineChart>+<XAxis>+<YAxis>+<CartesianGrid>+<Tooltip>+<Line> JSX from PrometheusChartWidget.tsx into components/LineSeriesChart.tsx. Props: { series: ChartSeries[], height?: number }. PrometheusChartWidget becomes: useWidgetData<LineSeriesChart series={data.data.series} />. Export ChartSeries type from LineSeriesChart for reuse. PrometheusChartWidget's existing tests MUST stay green — the extraction is mechanical (move + import), not a rewrite.
  • 2.4 Create QbittorrentTotalsWidget component

    • Files: frontend/src/widgets/QbittorrentTotalsWidget.tsx (NEW)
    • Lines: ~60
    • Dependencies: 2.3
    • Details: Renders {total, by_state} from useWidgetData. Uses SectionCard + prominent total count + state breakdown badges (e.g. downloading: 3, uploading: 1, paused: 5). Loading skeleton + error alert + empty state. Model on BackupsWidget/MetricCard patterns.
  • 2.5 Create QbittorrentActiveTorrentsWidget component

    • Files: frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx (NEW)
    • Lines: ~80
    • Dependencies: 2.3
    • Details: Renders {torrents: [...]} from useWidgetData. Uses SectionCard + compact table/list (manual Table rows, NOT DataTable — keep it simple for ≤10 active torrents). Columns: name, state badge, progress bar, dl/up speed. Scrollable container for overflow. Loading + error + empty ("No active torrents") states. Reuses humanSize-style formatting for speeds (bytes/s → MB/s).
  • 2.6 Create QbittorrentSpeedWidget component

    • Files: frontend/src/widgets/QbittorrentSpeedWidget.tsx (NEW)
    • Lines: ~40
    • Dependencies: 2.3
    • Details: Renders {series} from useWidgetData — IDENTICAL pattern to PrometheusChartWidget. Thin wrapper: useWidgetData<LineSeriesChart series={data.data.series} height={220} />. Loading skeleton + error alert + empty state ("No speed data yet"). Refresh interval from registry binding (5s).
  • 2.7 Add qBittorrent binding to frontend registry

    • Files: frontend/src/integrations/registry.ts (MODIFY)
    • Lines: ~40
    • Dependencies: 2.4, 2.5, 2.6
    • Details: Import the three new widget components. Add qbittorrent entry to SERVICE_REGISTRY with three widget kinds: totals (30s, QbittorrentTotalsWidget), active (15s, QbittorrentActiveTorrentsWidget), speed (5s, QbittorrentSpeedWidget). All with empty config schemas ({type:"object", properties:{}, required:[]}). Update barrel widgets/index.ts with the three new exports.
  • 2.8 Create frontend tests for new widgets + LineSeriesChart

    • Files: frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx (NEW), frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx (NEW), frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx (NEW), frontend/src/components/__tests__/LineSeriesChart.test.tsx (NEW)
    • Lines: ~110 (4 files × ~25-30 lines)
    • Dependencies: 2.3, 2.4, 2.5, 2.6
    • Details: Each widget test: loading skeleton (data-slot="skeleton"), error alert (destructive variant), rendered data case. LineSeriesChart.test.tsx: renders lines from series data, empty state (no series). Model on existing PrometheusChartWidget.test.tsx mock pattern (vi.mocked(useWidgetData).mockReturnValue(...)). Verify PrometheusChartWidget.test.tsx still passes after extraction.
  • 2.9 Verify Slice 2 (full stack)

    • Run: cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_widgets.py -v
    • Run: cd frontend && npm run lint && npm run build && npx vitest run src/widgets/__tests__/Qbittorrent src/widgets/__tests__/PrometheusChart src/components/__tests__/LineSeriesChart
    • Verify: PrometheusChartWidget tests GREEN (extraction not a regression); qBit widget tests GREEN; build + lint GREEN.

Slice 2 total: ~350400 changed lines.


Slice 3: MediaIndex migration onto harness (load-bearing refactor)

Goal: Register MediaIndex as a harness concern, add service_id scoping to the schema, fix the latent global-clear bug in replace_items, and thread service_id through query/build/worker. All existing MediaIndex tests MUST pass unchanged.

  • 3.1 Define MEDIA_INDEX_CONCERN and register with harness

    • Files: backend/src/media_library_viewer_api/services/media_index_impl.py (MODIFY — add concern constant near top), backend/src/media_library_viewer_api/services/service_data.py (MODIFY — register concern in get_service_data_harness)
    • Lines: ~25
    • Dependencies: Slice 1 (1.1)
    • Details: MEDIA_INDEX_CONCERN = StorageConcern(concern_key="media_index", db_filename="media_index.sqlite", migrations=["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"], tables=["media_items"]). Register in harness singleton alongside QBITTORRENT_CONCERN. The db_filename MUST match the existing DEFAULT_INDEX_PATH leaf name (media_index.sqlite). The harness base_dir (.cache/media_library_viewer) is the same parent — no file move.
  • 3.2 Add service_id column to init_schema CREATE TABLE

    • Files: backend/src/media_library_viewer_api/services/media_index_impl.py (MODIFY, init_schema ~line 107)
    • Lines: ~3
    • Dependencies: 3.1
    • Details: Add service_id TEXT NOT NULL DEFAULT '' as the last column in the CREATE TABLE IF NOT EXISTS media_items statement. New installs get the column from init_schema; existing DBs get it from the harness ALTER migration. Both paths converge on the same schema.
  • 3.3 Scope replace_items by service_id (FIXES latent global-clear bug)

    • Files: backend/src/media_library_viewer_api/services/media_index_impl.py (MODIFY, replace_items ~line 166)
    • Lines: ~20
    • Dependencies: 3.2
    • Details: RISK ITEM (b). Add service_id: str = "" parameter. Add "service_id" to the columns list. Change DELETE FROM media_itemsDELETE FROM media_items WHERE service_id = ? with (service_id,). Change the executemany rows to prepend service_id to each row's values. This FIXES the latent bug where building for one Jellyfin wipes another's rows. Existing tests call replace_items(rows) with no service_id → defaults to "" → deletes WHERE service_id = '' → backward-compatible (existing test data has service_id='' from backfill).
  • 3.4 Scope query by service_id

    • Files: backend/src/media_library_viewer_api/services/media_index_impl.py (MODIFY, query ~line 279)
    • Lines: ~10
    • Dependencies: 3.2
    • Details: Add service_id: str = "" parameter. After existing WHERE clause building, add: if service_id: where.append("service_id = ?"); params.append(service_id). When service_id="" (empty), skip the filter → shows all rows (backward-compatible). This means existing tests (which pass no service_id) see all rows unchanged.
  • 3.5 Thread service_id through build_media_indexreplace_items

    • Files: backend/src/media_library_viewer_api/services/media_index_impl.py (MODIFY, build_media_index ~line 331)
    • Lines: ~5
    • Dependencies: 3.3
    • Details: Add service_id: str = "" parameter to build_media_index(...). Change the count = index.replace_items(normalized_rows) call (~line 458) to count = index.replace_items(normalized_rows, service_id=service_id).
  • 3.6 Pass service_id from worker to build_media_index

    • Files: backend/src/media_library_viewer_api/workers/media_index_worker.py (MODIFY, run_build ~line 144, build_media_index call ~line 168)
    • Lines: ~3
    • Dependencies: 3.5
    • Details: The worker's run_build(final_index_path, staging_index_path, service_id="") ALREADY receives service_id from argparse (--service-id). Change the build_media_index(...) call (~line 168) to pass service_id=service_id.
  • 3.7 Add jellyfin_service_id to query_media and pass to query

    • Files: backend/src/media_library_viewer_api/routers/media.py (MODIFY, query_media ~line 276)
    • Lines: ~5
    • Dependencies: 3.4
    • Details: query_media does NOT currently have a jellyfin_service_id param (unlike post_build_index which does). Add jellyfin_service_id: str | None = None to the signature (after offset). Pass service_id=jellyfin_service_id or "" to the index.query(...) call (~line 310). Existing callers that don't send the param → None"" → all rows (backward-compatible).
  • 3.8 Add backend tests for scoped MediaIndex operations

    • Files: backend/tests/test_media_index.py (EXTEND)
    • Lines: ~45
    • Dependencies: 3.3, 3.4
    • Details: Add tests for the NEW scoped behavior: replace_items(rows, service_id="svc-a") then replace_items(other_rows, service_id="svc-b") — assert svc-a rows survive svc-b's replace (proves the bug fix). query(service_id="svc-a") returns only svc-a rows. query(service_id="") returns all rows. query() (no arg) returns all rows. Existing MediaIndex tests MUST pass unchanged — verify they don't break by running the full file.
  • 3.9 Verify Slice 3 (backend, load-bearing)

    • Run: cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_media_index.py -v
    • Run: cd backend && PYTHONPATH=src python -m pytest -q (full suite — no regressions)
    • Verify: ALL existing MediaIndex tests pass unchanged; new scoped tests pass; full suite green.

Slice 3 total: ~200260 changed lines. Backend-only; Media page UX unchanged.


Slice 4: Cascade-delete wiring end-to-end + integration test

Goal: Hook ServiceDataHarness.cascade_delete into SettingsStore.delete_service so removing a service wipes its owned data across all harness-managed tables. End-to-end integration test.

  • 4.1 Wire harness cascade-delete into delete_service

    • Files: backend/src/media_library_viewer_api/services/settings_store.py (MODIFY, delete_service ~line 1742)
    • Lines: ~10
    • Dependencies: Slice 1 (1.1), Slice 3 (3.1)
    • Details: After the existing DELETE FROM services WHERE id = ? (line 1754), add a try/except block: from media_library_viewer_api.services.service_data import get_service_data_harness; get_service_data_harness().cascade_delete(service_id). Log on failure (logger.exception(...)) — cascade is best-effort (service row is already deleted; data cleanup must not block deletion). Local import to avoid circular dependency.
  • 4.2 Add integration test for cascade-delete

    • Files: backend/tests/test_services.py (EXTEND)
    • Lines: ~45
    • Dependencies: 4.1
    • Details: Test end-to-end cascade: (1) create a qBittorrent service, append speed samples for it, delete the service → assert samples gone from qbittorrent.db. (2) Create two Jellyfin services, build media for both with distinct service_ids, delete one → assert only that service's media rows removed, other survives. Use tmp_path for both settings DB and harness base_dir. Verify delete_service doesn't raise even if harness fails (best-effort guard).
  • 4.3 Verify Slice 4 (full suite)

    • Run: cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest -q
    • Run: cd frontend && npm run lint && npm run build
    • Verify: full backend suite green (293+ tests + new); frontend unaffected (no FE changes in this slice).

Slice 4 total: ~80110 changed lines. Backend-only.


Integration and acceptance verification

  • 5.1 Full backend test run

    • Run: cd backend && PYTHONPATH=src python -m pytest -q
    • Verify: all existing tests pass + all new tests (harness, store, client, widget adapter, scoped MediaIndex, cascade-delete) pass.
  • 5.2 Full frontend build + lint

    • Run: cd frontend && npm run lint && npm run build
    • Verify: no TypeScript errors; no new lint failures; PrometheusChartWidget tests still green after LineSeriesChart extraction.
  • 5.3 Verify qBittorrent end-to-end (manual or integration)

    • Verify: a qBittorrent service can be registered; three widget kinds are bindable; totals shows item count; active shows DL/UL torrents; speed shows a live recharts line chart.
  • 5.4 Verify MediaIndex backward-compat

    • Verify: existing Media page works identically (no service_id → sees all rows); building for a specific Jellyfin service scopes correctly.
  • 5.5 Verify cascade-delete

    • Verify: deleting a service removes its qBit samples + media items; deleting one of multiple Jellyfin services removes only its rows.

Total estimate

Slice Changed lines Risk
Slice 1: Harness + store + client + integration ~310390 Low (new code, no existing behavior touched)
Slice 2: Widget adapter + LineSeriesChart + FE widgets ~350400 Medium (touches PrometheusChartWidget)
Slice 3: MediaIndex migration ~200260 High (load-bearing; must fix replace_items bug + keep tests green)
Slice 4: Cascade-delete wiring ~80110 Low (small, well-isolated)
Integration tests ~45 Low
Total ~9851,205

Guard lines

Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High

Risk callouts (for reviewer + apply agents)

# Risk Slice Mitigation
R1 LineSeriesChart extraction breaks PrometheusChartWidget tests S2 (2.3, 2.8) Extraction is mechanical (move JSX + helpers, pass series as prop). S2 exit gate explicitly verifies PrometheusChartWidget.test.tsx still passes.
R2 MediaIndex migration breaks existing tests or introduces a regression S3 (3.3, 3.8) service_id defaults to ""; empty-string queries skip the filter → all rows visible. replace_items bug fix changes DELETE FROM media_itemsDELETE WHERE service_id = ? with "" default → existing tests (which use "") are unaffected. S3 exit gate runs full backend suite.
R3 ALTER TABLE fails on fresh installs where init_schema already created the column S3 (3.1) run_migrations catches "duplicate column name" per-statement (designed in Slice 1).
R4 Harness lazy-init hides migration failures on startup S1 (1.6) run_migrations runs on first access in lifespan; failures raise (only "duplicate column name" is swallowed).
R5 Circular import between settings_store and service_data S4 (4.1) Cascade-delete uses local import inside delete_service method, not module-level.