github Kha-kis/arr-dashboard v2.20.0
v2.20.0 — qui integration: torrent observability, cross-seed integrity lens, passkey hardening

latest releases: v2.24.1, v2.24.0, v2.23.0...
3 months ago

[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

  • LibraryCache gained 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 value ServiceType.QUI.
  • ServiceInstance gained two qui-only columns: hasLocalFilesystemAccess Boolean (toggles inode-based correlation) and pathPrefix String? ("qui-prefix>local-prefix" rewrite for mount-mapped setups).
  • QuiActionLog model for the My Actions audit feed.
  • QuiWebhookConfig model + hashedQuiWebhookSecret for 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 into LibraryCache. No-op when no qui instance is configured.
  • infohash-backfill (catch-up at startup → 6h steady-state) — walks LibraryCache rows missing infoHash, queries each *arr's /api/v3/history/movie or /api/v3/history/series to 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 when hasLocalFilesystemAccess is 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 /qui drops 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/attention requests share one paginated fetch instead of running two.
  • Container memory ceiling (mem_limit: 3g in docker-compose.yml) as a circuit breaker against a runaway. Two Node processes × --max-old-space-size=768 leaves comfortable headroom; raise if running a very large library or HEAP_AUTO_SNAPSHOT.
  • Cache hygieneDELETE /services/:id invalidates 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.ts split 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-drawer decomposed into per-section files under features/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 Access in the qui service form; pair with Path Prefix Rewrite if 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/trackers endpoint previously returned the raw qBittorrent announce URL — including the user's tracker passkey — in each entry's url field. 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 a hostname via extractHostnameSafe before 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.transform so every consumer inherits it automatically; the tracker field on QuiCrossSeedMatch is documented as hostname-only on the shared schema. extractHostnameSafe is 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-probe path now requires QUI_DEBUG_INODE_PROBE=1 in 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 eventType filter (#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=0 is honored as a kill switch (#477, closes #471) for the heap monitor — previously the snapshotter could fire regardless.

Performance + memory

  • MALLOC_ARENA_MAX=2 set as the container default (#478, addresses #427 / #471). Heap-monitor payload now surfaces rssMB / heapTotalMB ratio 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.

Maintenance

  • Cross-seed discriminated-union narrowing (#484) future-proofs the match-type switch so adding a new match type doesn't silently fall through.
  • arr-sdk 0.7.0 → 0.7.1 (#480), turbo 2.9.6 → 2.9.14 (#481), production and dev Dependabot sweeps (#469, #483).

Don't miss a new arr-dashboard release

NewReleases is sending notifications on new releases.