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.
23 KiB
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,180–1,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 1–2 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
ServiceDataHarnesslifecycle module- Files:
backend/src/media_library_viewer_api/services/service_data.py(NEW) - Lines: ~100
- Dependencies: none
- Details:
StorageConcerndataclass (concern_key,db_filename,migrations: list[str],tables: list[str],service_id_column="service_id").ServiceDataHarnessclass withregister(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 singletonget_service_data_harness()that lazy-inits withbase_dir = BACKEND_CACHE_DIR or ".cache/media_library_viewer"and callsrun_migrations(). NO generic value table, NO generic CRUD.
- Files:
-
1.2 Create
QbittorrentSampleStorewith 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 createsqbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)+ index on(service_id, ts),tables=["qbittorrent_speed_samples"]).MAX_SAMPLES = 120.QbittorrentSampleStoreclass: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). RegisterQBITTORRENT_CONCERNwith harness on import.
- Files:
-
1.3 Create
QbittorrentClientHTTP 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 onJellyfinClient's session pattern._login()POSTs to/auth/loginwithRefererheader, expects"Ok."response, stores SID cookie._get(path, **params)auto-logins on first call, re-logins on 403.maindata()calls/sync/maindatareturning{server_state: {...}, torrents: {hash: {...}}}. Base URL normalization: append/api/v2if not present. Timeout +requests.RequestExceptionhandling consistent with existing clients.
- Files:
-
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_kindfrombase.py) - Details:
QbittorrentConfig(ServiceConfigBase)withbase_url: ServiceBaseUrl,timeout_seconds: int = 10.QbittorrentWidgetConfig(WidgetConfigBase)empty (all three widget kinds derive from service connection).DEFINITION = ServiceDefinition(service_type="qbittorrent", ...)withsecret_fields=[username (required), password (required)]and threewidget_kinds:totals(30s refresh),active(15s),speed(5s). Model onintegrations/prometheus.py.
- Files:
-
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 QBITTORRENTfromintegrations/qbittorrent.py; addSERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT. Verifylist_service_types()now includesqbittorrent.
- Files:
-
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()afterget_settings_store().ensure_defaults(), add a try/except block callingget_service_data_harness()(triggers lazy init + migration run). Log on failure (same pattern as the settings seed block at lines 51–54). Import is local inside lifespan to avoid circular import risk.
- Files:
-
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). TestQbittorrentSampleStore:append+ prune (append 130 samples, assert only 120 remain),windowreturns ordered samples, two services don't cross-contaminate. Usetmp_pathfor base_dir isolation.
- Files:
-
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. Useunittest.mock.patchon the session.
- Files:
-
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.
- Run:
Slice 1 total: ~310–390 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
QbittorrentWidgetSourceadapter towidgets/sources.py- Files:
backend/src/media_library_viewer_api/widgets/sources.py(MODIFY) - Lines: ~75
- Dependencies: Slice 1 (1.3, 1.2)
- Details: Add
QbittorrentWidgetSourceimplementingWidgetSource.fetch(service, widget_kind, config). Resolves client inline fromServiceRecordconfig+secrets (likePrometheusWidgetSource). Three branches:totals:len(torrents)+by_statedict breakdown. Returns{total, by_state}.active: filterstate in {"downloading","uploading"}. Returns{torrents: [{name, state, size, progress, dl_speed, up_speed}]}.speed: append sample toQbittorrentSampleStore(service.id, ts, dl, up), readwindow(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()toSERVICE_ADAPTERS.
- Files:
-
2.2 Add backend tests for qBittorrent widget adapter
- Files:
backend/tests/test_widgets.py(EXTEND) - Lines: ~90
- Dependencies: 2.1
- Details: Test
QbittorrentWidgetSourcewith mockedQbittorrentClient.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": ...}. Usetmp_pathharness override for the store.
- Files:
-
2.3 Extract shared
LineSeriesChartcomponent fromPrometheusChartWidget- 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/SeriesPointtypes, and the<ResponsiveContainer>+<LineChart>+<XAxis>+<YAxis>+<CartesianGrid>+<Tooltip>+<Line>JSX fromPrometheusChartWidget.tsxintocomponents/LineSeriesChart.tsx. Props:{ series: ChartSeries[], height?: number }.PrometheusChartWidgetbecomes:useWidgetData→<LineSeriesChart series={data.data.series} />. ExportChartSeriestype fromLineSeriesChartfor reuse. PrometheusChartWidget's existing tests MUST stay green — the extraction is mechanical (move + import), not a rewrite.
- Files:
-
2.4 Create
QbittorrentTotalsWidgetcomponent- Files:
frontend/src/widgets/QbittorrentTotalsWidget.tsx(NEW) - Lines: ~60
- Dependencies: 2.3
- Details: Renders
{total, by_state}fromuseWidgetData. UsesSectionCard+ prominent total count + state breakdown badges (e.g.downloading: 3,uploading: 1,paused: 5). Loading skeleton + error alert + empty state. Model onBackupsWidget/MetricCardpatterns.
- Files:
-
2.5 Create
QbittorrentActiveTorrentsWidgetcomponent- Files:
frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx(NEW) - Lines: ~80
- Dependencies: 2.3
- Details: Renders
{torrents: [...]}fromuseWidgetData. UsesSectionCard+ compact table/list (manualTablerows, 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. ReuseshumanSize-style formatting for speeds (bytes/s → MB/s).
- Files:
-
2.6 Create
QbittorrentSpeedWidgetcomponent- Files:
frontend/src/widgets/QbittorrentSpeedWidget.tsx(NEW) - Lines: ~40
- Dependencies: 2.3
- Details: Renders
{series}fromuseWidgetData— IDENTICAL pattern toPrometheusChartWidget. 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).
- Files:
-
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
qbittorrententry toSERVICE_REGISTRYwith three widget kinds:totals(30s,QbittorrentTotalsWidget),active(15s,QbittorrentActiveTorrentsWidget),speed(5s,QbittorrentSpeedWidget). All with empty config schemas ({type:"object", properties:{}, required:[]}). Update barrelwidgets/index.tswith the three new exports.
- Files:
-
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 existingPrometheusChartWidget.test.tsxmock pattern (vi.mocked(useWidgetData).mockReturnValue(...)). VerifyPrometheusChartWidget.test.tsxstill passes after extraction.
- Files:
-
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.
- Run:
Slice 2 total: ~350–400 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_CONCERNand 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 inget_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 alongsideQBITTORRENT_CONCERN. Thedb_filenameMUST match the existingDEFAULT_INDEX_PATHleaf name (media_index.sqlite). The harnessbase_dir(.cache/media_library_viewer) is the same parent — no file move.
- Files:
-
3.2 Add
service_idcolumn toinit_schemaCREATE 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 theCREATE TABLE IF NOT EXISTS media_itemsstatement. New installs get the column frominit_schema; existing DBs get it from the harness ALTER migration. Both paths converge on the same schema.
- Files:
-
3.3 Scope
replace_itemsbyservice_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. ChangeDELETE FROM media_items→DELETE FROM media_items WHERE service_id = ?with(service_id,). Change theexecutemanyrows to prependservice_idto each row's values. This FIXES the latent bug where building for one Jellyfin wipes another's rows. Existing tests callreplace_items(rows)with noservice_id→ defaults to""→ deletesWHERE service_id = ''→ backward-compatible (existing test data hasservice_id=''from backfill).
- Files:
-
3.4 Scope
querybyservice_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). Whenservice_id=""(empty), skip the filter → shows all rows (backward-compatible). This means existing tests (which pass noservice_id) see all rows unchanged.
- Files:
-
3.5 Thread
service_idthroughbuild_media_index→replace_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 tobuild_media_index(...). Change thecount = index.replace_items(normalized_rows)call (~line 458) tocount = index.replace_items(normalized_rows, service_id=service_id).
- Files:
-
3.6 Pass
service_idfrom worker tobuild_media_index- Files:
backend/src/media_library_viewer_api/workers/media_index_worker.py(MODIFY,run_build~line 144,build_media_indexcall ~line 168) - Lines: ~3
- Dependencies: 3.5
- Details: The worker's
run_build(final_index_path, staging_index_path, service_id="")ALREADY receivesservice_idfrom argparse (--service-id). Change thebuild_media_index(...)call (~line 168) to passservice_id=service_id.
- Files:
-
3.7 Add
jellyfin_service_idtoquery_mediaand pass toquery- Files:
backend/src/media_library_viewer_api/routers/media.py(MODIFY,query_media~line 276) - Lines: ~5
- Dependencies: 3.4
- Details:
query_mediadoes NOT currently have ajellyfin_service_idparam (unlikepost_build_indexwhich does). Addjellyfin_service_id: str | None = Noneto the signature (afteroffset). Passservice_id=jellyfin_service_id or ""to theindex.query(...)call (~line 310). Existing callers that don't send the param →None→""→ all rows (backward-compatible).
- Files:
-
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")thenreplace_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.
- Files:
-
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.
- Run:
Slice 3 total: ~200–260 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.
- Files:
-
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 distinctservice_ids, delete one → assert only that service's media rows removed, other survives. Usetmp_pathfor both settings DB and harness base_dir. Verifydelete_servicedoesn't raise even if harness fails (best-effort guard).
- Files:
-
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).
- Run:
Slice 4 total: ~80–110 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.
- Run:
-
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.
- Run:
-
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.
- Verify: existing Media page works identically (no
-
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 | ~310–390 | Low (new code, no existing behavior touched) |
| Slice 2: Widget adapter + LineSeriesChart + FE widgets | ~350–400 | Medium (touches PrometheusChartWidget) |
| Slice 3: MediaIndex migration | ~200–260 | High (load-bearing; must fix replace_items bug + keep tests green) |
| Slice 4: Cascade-delete wiring | ~80–110 | Low (small, well-isolated) |
| Integration tests | ~45 | Low |
| Total | ~985–1,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_items → DELETE 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. |