Resolve Q1 (reuse Change A's PrometheusChartWidget renderer via InService data path), Q2 (totals = item count, not transfer bytes), Q3 (active = downloading/uploading), Q4 (N instances), Q5 (username/password cookie auth). Remove TBD/stale thin-dashboard caveats.
14 KiB
SDD Proposal: Service Storage Harness (with qBittorrent widgets + MediaIndex migration)
Change: service-storage-harness
Phase: proposal
Date: 2026-07-08
1. Problem / Why Now
Two things are happening at once, and this change addresses both:
-
Feature request — qBittorrent widgets. The operator wants at-a-glance qBittorrent visibility on the Manage dashboard: active downloads/uploads, total bytes transferred, and a download/upload speed indicator. qBittorrent is currently a blind spot — it is not modeled in the service registry and exposes no widgets.
-
Platform gap — services have no owned persistence. Manage already has a service registry (machines, Jellyfin, Grafana, Prometheus, Alertmanager, Jellyseerr, ssh_tasks) and a configurable widget system. But when a service needs to remember operational data over time, there is no shared answer.
MediaIndexis a one-off: it owns its ownmedia_index.db, runs its own schema, and is built by a dedicated subprocess worker. It is a "special snowflake." Rather than add a second snowflake for qBittorrent speed history, we extract the shared lifecycle into a smallServiceDataHarnessand prove it with qBittorrent, then migrateMediaIndexonto it so the pattern has two evidence-based consumers.
The two are bundled because the second service (qBittorrent) is the one that justifies generalizing from the first (MediaIndex) — n=2 is what makes the abstraction worth its cost.
2. Target Users and Situations
- Primary users: Homelab operators running qBittorrent alongside Manage, who want download/upload activity visible without opening the qBittorrent UI.
- Workflow moments:
- Glance at the dashboard: "is a download running, how fast, how much has been transferred?"
- Decide whether qBittorrent is healthy without leaving Manage.
- Browse the Jellyfin media catalog (unchanged UX) — which now runs on the same storage harness, validating the abstraction.
- Urgency: Medium. The feature is valuable but not breaking; the platform refactor is opportunistic (do it now while only two consumers exist, before a third snowflake appears).
3. Product Outcome
After this change, an authenticated user can:
- Register one or more qBittorrent service instances in the existing Services UI (URL + username + password, stored as encrypted secrets — same posture as Grafana/Prometheus).
- Place three discrete qBittorrent widget kinds on the dashboard:
- Totals tile — count of currently-listed torrents (total items in the qBittorrent list, which may exceed the active count because rate/connection limits leave some torrents non-transferring). NOT cumulative bytes transferred.
- Active torrents list — torrents whose state is
downloadingoruploading. - Speed chart — live download/upload speed over a short rolling window, rendered with the
PrometheusChartWidgetrecharts renderer established by theprometheus-direct-chartingchange (fed from theQbittorrentSampleStorevia an InService-style data path returning the same{series}shape). No hand-rolled SVG sparkline; the thin-dashboard rule was already repealed for recharts charting by the foundational change.
- Continue using the Jellyfin Media page exactly as before; its underlying storage moves onto the harness transparently and additionally becomes multi-instance capable (scoped per Jellyfin service).
4. Scope Boundaries and Non-Goals
In scope
ServiceDataHarness— a general lifecycle layer: per-concern DB files, per-integration schema migrations,service_idscoping of all tables, and cascade-delete when a service instance is removed.- qBittorrent integration — new
integrations/qbittorrent.py(config + secret schema + widget kinds), newclients/qbittorrent.py(Web API client, cookie login), aQbittorrentSampleStore(speed samples), widget source adapter(s) inwidgets/sources.py, registry entry. - qBittorrent frontend — three widget components under
frontend/src/widgets/, binding inintegrations/registry.ts, types, API client functions. - MediaIndex migration —
MediaIndexregistered with the harness,media_itemsscoped byservice_id, subprocess worker updated. Themedia_index.dbfile location is unchanged (only gains a column + harness registration) to minimize churn on a load-bearing feature. - Cascade-delete wiring — removing a service instance cleans up its owned data in every harness-managed table.
Non-goals (explicitly out of scope)
- Torrent management UI — no add/pause/delete/recheck/priority UI. Read-only visibility only.
- A generic time-series database. The harness owns lifecycle, not a generic
(service_id, key, ts, value)table. Each integration owns its own schema and operations. - A generic CRUD/ORM layer. Stores keep bespoke operations (
append/windowvsreplace_all/query); only the lifecycle is shared. - Per-user or per-tenant storage partitioning. Storage is scoped by
service_idonly. - Re-indexing/migrating existing media data. The
media_index.dbfile stays in place; the migration is a schema column add + harness registration, not a data move. - WebSocket / push updates. Polling via existing
useWidgetData(widgetId, refreshIntervalMs)is sufficient. - Transfer-byte totals. The totals widget counts torrent items, not cumulative bytes uploaded/downloaded (per the §8 Q2 resolution). Byte totals are out of scope.
- qBittorrent Prometheus exporter. Not built; the speed chart is sourced from the local
QbittorrentSampleStore, not Prometheus.
5. High-Level Approach
5.1 Architectural decisions (locked during grilling)
| Decision | Outcome |
|---|---|
| Storage tech | SQLite. Low volume; proven in stack. |
| Abstraction level | ServiceDataHarness owns the lifecycle (DB filename, migrations, service_id scoping, cascade-delete). Each integration owns its operations in a bespoke Store. General where shared; bespoke where not. |
| MediaIndex migration | Included in this change, but sequenced: harness + qBit proven first; MediaIndex folded in after. Inside one change, never two simultaneous risks. |
| DB topology | Per-concern DB files. media_index.db keeps its file (gains service_id + registration); new qbittorrent.db. |
5.2 Backend
-
ServiceDataHarness(new,services/service_data.pyor similar):- A registry of concerns: each integration declares a DB filename, an ordered list of migration SQL statements, the tables it owns, and a
service_idcolumn convention. - On startup: runs pending migrations per concern DB.
- On service deletion: cascades — for each concern table,
DELETE FROM <table> WHERE service_id = ?. - Provides connection management per concern DB (separate connections → bulk media writes don't share a writer with chatty qBit appends).
- A registry of concerns: each integration declares a DB filename, an ordered list of migration SQL statements, the tables it owns, and a
-
qBittorrent client (
clients/qbittorrent.py):- Login via
/api/v2/auth/login→ cookie session; reuse cookie across calls; re-login on 403. - Endpoints used:
/api/v2/transfer/info(global totals),/api/v2/sync/maindata(active torrents + current speeds),/api/v2/torrents/info(filtered lists if needed). - Timeout + reachability handling consistent with
JellyfinClient/GrafanaWidgetSource.
- Login via
-
QbittorrentSampleStore(new):- Table
qbittorrent_speed_samples(service_id TEXT, ts INTEGER, dl_speed INTEGER, up_speed INTEGER)inqbittorrent.db. - Operations:
append(service_id, ts, dl, up),window(service_id, since_ts),prune(service_id, older_than_ts). - Pruning runs on each append (cap retention to the configured window, e.g. 120 samples).
- Table
-
Widget source adapter —
QbittorrentWidgetSourceinwidgets/sources.pyimplementing the existingWidgetSource.fetch(service, widget_kind, config)contract. For the speed kind,fetchappends a sample to the store and returns the current window for rendering. -
Integration registration — add
qbittorrenttointegrations/registry.pywith config schema (base URL, timeout) and secret schema (username, password); declare widget kinds. -
MediaIndex migration — register
MediaIndexas a concern; addservice_idcolumn (backfill existing rows with the default/local Jellyfin service id); updatereplace_items/query/worker to scope byservice_id; route through the harness connection.
5.3 Frontend
- Three widget components under
frontend/src/widgets/:QbittorrentTotalsWidget.tsx— numeric tiles for all-time bytes (reusesMetricCard/SectionCard).QbittorrentActiveTorrentsWidget.tsx— list/table of active torrents (reuses the already-migratedDataTable+@tanstack/react-table; no new DataGrid migration risk).QbittorrentSpeedWidget.tsx— visual form decided by §8 Q1 (numeric tiles + delta, or sparkline if an exception is granted, or a PrometheusMetricWidget binding if the exporter path is chosen).
- Registry binding — add a
qbittorrententry toSERVICE_REGISTRYinfrontend/src/integrations/registry.tsmapping the three widget kinds to components + config schemas. - Types + API client —
frontend/src/types/index.tsandfrontend/src/api/client.tsgain qBittorrent-aware widget kinds only (data flows through the existinguseWidgetDatapolling; no new endpoints beyond widget CRUD).
5.4 Type contracts
- Backend Pydantic models for qBittorrent config/secret schema in
integrations/qbittorrent.py. - Frontend TypeScript interfaces for qBittorrent widget payloads.
- Harness has no data-shape types of its own (it is lifecycle-only) — keeping the seam clean.
6. Success Criteria / Acceptance Criteria
- A user can register a qBittorrent service instance (URL + username + password) and the three widget kinds appear as bindable on the dashboard.
- Totals tile shows all-time bytes from
transferInfo; active list shows downloading/uploading torrents; speed indicator reflects current rates. ServiceDataHarnessruns migrations on startup and cascades deletes across qBittorrent samples and media items when a service is removed.MediaIndexcontinues to power the Media page identically (all existing Media tests green) and is now scoped byservice_id.- Removing a Jellyfin service removes only that service's media rows; removing a qBittorrent service removes only that service's speed samples.
- A misconfigured/unreachable qBittorrent instance degrades gracefully per-widget (error state), the rest of the dashboard renders.
- Backend tests (
pytest) and frontendnpm run build+npm run lintstay green. - No secrets land in widget
config_json; qBittorrent password is encrypted via the existing Fernet path. - No new charting dependency is added unless §8 Q1 grants an explicit exception.
7. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Compound risk: new abstraction × load-bearing refactor. A wrong harness shape breaks the Media page. | Sequence inside the change: harness + qBit land and pass tests first; MediaIndex migrates only after the harness shape is settled in code. |
| Harness over-generalization. Building a meta-framework from n=2. | Keep the harness lifecycle-only; do NOT add a generic value table or generic CRUD. Each store keeps bespoke operations. Extract further only when a third shape appears. |
| Thin-dashboard rule collision (§8 Q1). In-app speed graph violates the stated "no in-app charting" rule. | Resolve in the question round before spec; default to the rule-compliant numeric-tile + Grafana deep-link option unless an explicit exception is granted. |
| qBittorrent auth lifecycle. Cookie expiry / 403 handling. | Re-login transparently on 403; short request timeout; surface persistent auth failure as widget error state. |
MediaIndex backfill correctness. Adding service_id to existing rows. |
Backfill all existing rows to the default/local Jellyfin service id; migration is additive; existing Media tests must pass unchanged. |
| Review budget (>400 changed lines). Bundled scope is large. | Slice into chained PRs (Slice 1: harness + qBit; Slice 2: MediaIndex migration). Each slice leaves npm run build + npm run lint + pytest green. |
| DataGrid migration (config rule callout). | Not a risk here: the active-torrents list reuses the already-migrated DataTable (@tanstack/react-table). No new DataGrid migration is introduced. The key technical risks are the harness abstraction and the MediaIndex refactor, above. |
8. Resolved Questions (question round complete)
All five product/semantic questions resolved during grilling + the Change A (prometheus-direct-charting) lifecycle:
- Q1 — Speed visualization. RESOLVED: the thin-dashboard rule was repealed by Change A; in-app recharts charting is now sanctioned. The qBit speed widget reuses the
PrometheusChartWidgetrecharts renderer, fed fromQbittorrentSampleStore.window()via an InService data path returning the same{series}shape. No SVG sparkline, no Grafana dependency. - Q2 — "Totals" semantics. RESOLVED: count of currently-listed torrent items (total in the qBittorrent list), which may exceed the active count due to rate/connection limits. NOT transfer bytes, NOT per-session counters.
- Q3 — "Active" definition. RESOLVED: torrents in state
downloadingoruploadingonly. - Q4 — Instances. RESOLVED: support N qBittorrent instances, each a service row, independently scoped by
service_id. - Q5 — Auth model. RESOLVED: username/password login → cookie session, encrypted via Fernet. No reverse-proxy no-auth flag in this pass.
9. Future Phases
- Third service consumer — when a service with a genuinely new storage shape arrives, reconsider promoting the harness toward a broader abstraction (evidence-based, n=3).
- Torrent management — add/pause/delete actions (would require write endpoints + confirmation UX).
- Storage admin UI — surface harness-managed table sizes and a "clear cached data" action per service.
- Reverse-proxy / no-auth qBittorrent flag — for setups behind Authentik where native login is bypassed.
- Transfer-byte totals — if desired later, add a separate widget using
transferInfo.globalUploaded/Downloaded.