[2.20.0] - 2026-06-03 — qui Integration
This release introduces a deep, federated integration with autobrr/qui
(qBittorrent UI) that turns arr-dashboard into a unified surface for the *arr
stack and the torrent layer underneath it. Add a qui instance from
Settings → Services (port 7476 by default) — the rest of the surfaces light
up automatically.
Library — torrent observability (Phase 1.4 + 2.1)
- Per-card Torrent Health badge on the Library page — at-a-glance pill showing seeding state + ratio for every cached item correlated with a qui torrent.
- Per-card tracker brand icons — items show the real tracker logo derived from qui's announce-URL data + icon registry.
- Server-side Torrent state filter on the Library page with per-bucket counts (
Seeding (150) | Stalled download (3) | Not correlated with qui (1962) …). Pagination is honest under the filter — totals reflect the filtered universe. - Deep-link support —
/library?torrentState=<bucket>preselects the matching torrent-state filter (used by the qui home page Quick Actions and Pulse seeding-health card). - Sort by torrent ratio added to the Library sort dropdown.
- Pulse Seeding Health domain with a dedicated status badge in the dashboard footer following the standard 5-state taxonomy (healthy/degraded/offline/configured/disabled).
- Library Cleanup gate — cleanup proposals respect active seeding obligations and skip items currently uploading.
Per-torrent detail drawer (Phase 6)
A 480 px right sheet replaces the old kebab menu on every library item that
has a qui correlation. The drawer is capability-aware — it reads qBit's
WebAPI version and visibly disables actions the running qBit doesn't
support, naming the gap in a banner so the operator knows why.
- Status section — torrent state, save path, totals, peer counts, tracker brand pill row.
- Actions — pause/resume, force start, recheck, reannounce, set/remove super-seed (where supported). State-gated so download-queue actions only appear while progress < 1.
- Trackers — per-tracker peers, health aggregation, live speeds, DHT/PeX/LSD status (only reported as enabled when qBit's own health flags agree). Add / edit / remove via hostname (passkey-safe — full announce URLs never leave the API process).
- Tags + Category — type-ahead pickers backed by qBit's existing taxonomy.
- Limits — per-torrent share-limit, ratio cap, seed-time cap (ratio + seed-time are submitted as one share-limit operation).
- Behavior — auto-management, sequential download, first/last piece priority.
- Files — full file inventory with MediaInfo quality verification: arr-dashboard cross-checks the file's resolution/codec/container against qBit's claimed quality and flags drift.
- Advanced — rename torrent, set location (move data on disk). Both inputs mask their prefill in incognito mode.
- Danger zone — delete torrent only / delete torrent + data, with a typed confirmation.
The drawer ships with proper Radix tooltips throughout, a SheetDescription
for accessibility, and incognito masking on titles, paths, and instance
labels via the same useIncognitoMode() machinery the rest of the app
uses.
Series + movie torrent panels (Phase 5)
The detail modal on each Sonarr series and Radarr movie now shows the
torrent layer with the same fidelity the drawer does.
- Season-grouped per-torrent clustering with cross-references between episodes that share a torrent.
- Inline action menu on every cluster copy.
- Persistent inode index with startup pre-warm and a manual rebuild button — definitive hardlink correlation when arr-dashboard has filesystem access to both the qBit content tree and the *arr library tree.
- Movies get the same clustering and panel shape as series.
qui home page (/qui)
A new top-level Overview entry — the at-a-glance counterpart of /dashboard
for the torrent layer.
- Live throughput KPI — current download/upload rates with a live tick.
- Capability banner that lists qBit feature gaps relevant to the actions arr-dashboard surfaces.
- Library correlation card — how many cached items have qui torrents, with a one-click backfill trigger.
- Needs Attention feed synthesizing stuck-at-tracker torrents and other operator-actionable signals.
- Quick Actions linking into pre-filtered library views.
qui Activity (/qui-activity, Maintenance group)
A drill-down surface with four tabs:
- Activity feed — qui's own observation stream (torrent state transitions, etc.).
- My Actions — operator-initiated mutation audit log (every drawer/cluster action records here with hash + instance + outcome, including failures).
- My Events — raw qui webhook event log.
- Webhook — config panel with hashed-secret rotation and a recent-events strip proving the wire is live.
Torrent-state transitions also route into the notification engine, so the
existing notification rules can fire on qui-observed changes.
Queue Cleaner — last-seed protection
When *arr is the only known seeder of a torrent, queue-cleaner now skips
strikes against it. Per-instance toggle in the queue-cleaner config.
Schema
LibraryCachegained three columns:torrentState String?(normalized:seeding/downloading/stalled_dl/paused/queued/checking/moving/error/unknown)torrentRatio Float?torrentSyncedAt DateTime?
- New index on
torrentState(powers the filter dropdown query). One new enum valueServiceType.QUI. ServiceInstancegained two qui-only columns:hasLocalFilesystemAccess Boolean(toggles inode-based correlation) andpathPrefix String?("qui-prefix>local-prefix"rewrite for mount-mapped setups).QuiActionLogmodel for the My Actions audit feed.QuiWebhookConfigmodel +hashedQuiWebhookSecretfor the webhook receiver (plaintext only returned once at rotate time).- Migration:
pnpm --filter @arr/api run db:push— existing rows start NULL and populate as the backfill scheduler walks them.
Background jobs
qui-torrent-state-sync(10min interval) — snapshots torrent state from every enabled qui instance intoLibraryCache. No-op when no qui instance is configured.infohash-backfill(catch-up at startup → 6h steady-state) — walksLibraryCacherows missinginfoHash, queries each *arr's/api/v3/history/movieor/api/v3/history/seriesto populate the hash, then qui sync correlates. Catch-up loop drains existing libraries in ~5 min for typical sizes; hard-capped at 10k rows per startup.infohash-backfill-by-inode— definitive hardlink correlation whenhasLocalFilesystemAccessis on. Persistent inode index survives restarts via gzip-serialized snapshot.
Performance + operability
- Stale-while-revalidate cache for qui's full torrent list — first-paint of
/quidrops from ~3.5s to <100ms once warm; only the first request after a cold process start pays the paginated walk. - In-flight dedup so concurrent
/qui/summary+/qui/attentionrequests share one paginated fetch instead of running two. - Container memory ceiling (
mem_limit: 3gindocker-compose.yml) as a circuit breaker against a runaway. Two Node processes ×--max-old-space-size=768leaves comfortable headroom; raise if running a very large library orHEAP_AUTO_SNAPSHOT. - Cache hygiene —
DELETE /services/:idinvalidates both the torrent-list cache and the inode index for the deleted instance, so retired qui instances don't linger in memory.
Architecture
routes/qui.tssplit into domain-grouped files:instance-routes,torrent-routes,library-routes,panel-routes,action-routes,webhook-routes.- Per-action Zod payload schemas + an extended action allowlist in the action service.
torrent-detail-drawerdecomposed into per-section files underfeatures/library/components/torrent-drawer/.- New shared types in
packages/shared/src/types/qui.ts(~960 LOC of Zod schemas including normalized torrent state, transfer info, MediaInfo, monitored torrent).
Notes for operators
- *Coverage ceiling depends on arr history retention. The backfill scheduler can only correlate items whose original grab record is still in *arr's history. Items whose history has been pruned (Sonarr/Radarr default retention is finite) will never match a torrent and will sit in the "Not correlated with qui" bucket forever. To grow coverage, increase Settings → General → History Retention in your *arr instances.
- Cross-host setups — definitive hardlink-based correlation requires arr-dashboard process to have read access to both the qBit content tree and the *arr library tree. Enable per-instance via
Has Local Filesystem Accessin the qui service form; pair withPath Prefix Rewriteif mount points differ between qBit and arr-dashboard. - Capability gaps surface explicitly — old qBit versions get an amber banner in the drawer naming exactly which actions are disabled (super-seed, share-limit, etc.). Upgrade qBit to unlock them.
- Privacy mode — qui's per-instance label, the qBit instance name, torrent titles, file paths, save paths, and drawer rename/move inputs are all anonymized in incognito mode just like other ARR instance labels.
Cross-seed integrity lens (#492)
The cross-seed page reframes as an integrity lens for qui-sourced sibling visibility. Tracker health is now the primary signal — match-type was demoted from a tone-coded warning chip (the amber name-match was misreading as a problem when it's the normal case) to a quiet provenance chip with hover hint. A header "Open in qui" button bridges to the qui home page; per-row links omitted because qui has no stable per-torrent URL. The card uses the canonical resolveHostnameBrand resolver so apex-domain customizations in qui's tracker registry (e.g. beyond-hd.me) match siblings whose announce host is tracker.beyond-hd.me. Incognito mode skips the qui registry entirely so neither the brand name nor its icon leaks.
Security
- qui trackers route now ships hostname-only (#493, closes #491). The
GET /qui/instances/:id/qbit/:instanceId/torrents/:hash/trackersendpoint previously returned the raw qBittorrent announce URL — including the user's tracker passkey — in each entry'surlfield. Tracker passkeys are user-identifying tokens that act as credentials and are an ejection-worthy leak on most private trackers. The route now maps each entry to ahostnameviaextractHostnameSafebefore sending. Mutation routes (remove/edit) re-fetch the original URL server-side so the wire never carries the secret. - Cross-seed sibling display strips tracker passkeys at the wire transform boundary (#492). The strip is applied inside
wireCrossSeedMatchSchema.transformso every consumer inherits it automatically; thetrackerfield onQuiCrossSeedMatchis documented as hostname-only on the shared schema.extractHostnameSafeis now the single source of truth for tracker-identity stripping across the qui integration. - qui inode-probe debug endpoint env-gated (#485, CodeQL alert #197). The
/qui/debug/inode-probepath now requiresQUI_DEBUG_INODE_PROBE=1in addition to admin auth.
Statistics — Storage Available de-duplication (#490, closes #486)
The overview "Storage available" card now de-duplicates disks shared across *arr instances. On single-array Unraid/Docker setups, every *arr instance reports the same physical array's free space, and the dashboard summed them — a #486 reporter saw ~485 TB on a 105 TB array (≈4× the real figure). A new combineDiskStats helper folds disks by storage group first, then by a (totalSpace, freeSpace) fingerprint for instances without a group set. The card now reads "of X available · N disks across M instances" so a large total is inspectable rather than asserted. Manual Storage Groups remain as the explicit override. Mount paths are deliberately not carried on the wire — they aren't used for dedup and would leak the operator's layout.
Library Cleanup — rejection memory (#482, closes #474)
Items the operator declined now stay declined across cleanup runs — the cleaner no longer re-proposes the same item every cycle. Per-item audit log records when and why each rejection was registered.
Bug fixes
- Hunting detects grabs without the
eventTypefilter (#479, closes #472). Sonarr/Radarr versions that don't surface the field in history no longer silently miss new grabs. Paired with arr-sdk 0.7.1 (#480) which restores the server-side filter where it's available. - Query strings now percent-encode spaces correctly (#476, closes #470) in Seerr, Jellyfin, and Tautulli integrations.
HEAP_AUTO_SNAPSHOT=0is honored as a kill switch (#477, closes #471) for the heap monitor — previously the snapshotter could fire regardless.
Performance + memory
MALLOC_ARENA_MAX=2set as the container default (#478, addresses #427 / #471). Heap-monitor payload now surfacesrssMB / heapTotalMBratio so users can distinguish a JS leak (heap-driven) from glibc allocator behavior (RSS-driven). See [[rss-vs-heap-discriminator]] for the diagnostic rule of thumb.