Skip to content

feat: emulate qBittorrent API to report download progress to Sonarr/Radarr#21

Merged
roziscoding merged 18 commits into
mainfrom
feat/qbittorrent-api-emulation
Jun 7, 2026
Merged

feat: emulate qBittorrent API to report download progress to Sonarr/Radarr#21
roziscoding merged 18 commits into
mainfrom
feat/qbittorrent-api-emulation

Conversation

@roziscoding

@roziscoding roziscoding commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a qBittorrent WebUI API emulation at /api/v2/... so Sonarr/Radarr can register jack as a qBittorrent download client and poll it for download progress, reading the same downloads table jack already maintains. Auto-registration switches from a TorrentBlackhole client to a qBittorrent client, and qB-added downloads switch from jack-pushed import scans to *arr-pulled imports.

Built from the approved plan in ai_docs/plans/2026-06-06-qbittorrent-api-emulation/ (research + 4 phases, Oracle-reviewed).

What it does

  • AuthPOST /api/v2/auth/login validates the username against a configured servers[] connector name and the password against jack.apiKey, issuing a SID cookie. Mounted before the global apikey middleware; unauthenticated protected endpoints return 403 (qB convention).
  • AppGET /api/v2/app/{version,webapiVersion,preferences} report values tuned so *arr's connection test and priority/removal checks pass (webapiVersion 2.9.2).
  • ProgressGET /api/v2/torrents/{info,properties,files} map downloads rows to qB torrents: real BitTorrent infohash (so *arr keeps track), byte-based progress, pausedUP on completion, and content_path != save_path so *arr imports cleanly.
  • GrabPOST /api/v2/torrents/add accepts only jack stubs / jack download URLs; magnets and foreign torrents are rejected (415). delete/setCategory are scoped to the calling server's rows.
  • Pull model — qB-added downloads (those with qb_source_server set) skip jack's triggerImport; blackhole-added downloads keep the existing push.
  • Auto-registration — jack registers itself as a qBittorrent download client (QBittorrentSettings, per-app movieCategory/tvCategory), upgrading any prior TorrentBlackhole "Jack" client in place, and keeps the indexer↔client binding.

Key correctness note

*arr computes the infohash itself from the grabbed .torrent and matches torrents/info by it (TorrentClientBase.cs:200). So jack reports the served stub's real infohash (sha1 of the bencoded info dict from release title+size) via a shared getStubInfoHash in torrent.ts — not sha1(peerId:itemId), which would make *arr lose track of every download.

Schema

Migration 0002 adds nullable qb_category / qb_source_server columns to downloads.

Testing

  • 165 backend unit/integration tests pass (bun test apps/backend/src/); lint clean; no new type errors.
  • New suites: qbittorrent-mapper, qbittorrent-api (auth/app/mapping/add/delete/setCategory/no-ops/503), integration (qB registration + blackhole→qB upgrade), database (column round-trip).
  • e2e/tests/qbittorrent-flow.test.ts is written but requires the live Docker stack (run cd e2e && bun test qbittorrent-flow).

Reviewed

  • Oracle plan review applied (notably the infohash fix).
  • Post-implementation code review: COMPLIANT / APPROVED. Validation: COMPLETE.
  • Follow-ups applied: add returns 503 (not 415) on missing downloads config; session store sweeps expired SIDs on login.

Out of scope

No real BitTorrent (no peer wire / seeding), no sync/maindata, no setPreferences. The blackhole watcher path is untouched.

Update — peer-serving backpressure + retry backoff

Two fixes added on top of the qBittorrent work, root-caused from a real incident where a single interrupted download made a peer unreachable for ~17 minutes.

fix: serve peer files with native backpressure to prevent OOM

The peer file endpoint served downloads via a hand-pumped Bun.file().stream(), which does not apply TCP backpressure. When the downloading peer stalls or aborts mid-transfer (e.g. it was restarted), the serving side reads the entire file into memory as fast as it can — starving the event loop and exhausting RAM. On a swapless host, one stalled stream of a large file is enough to hard-freeze the process until the kernel OOM-kills it.

Fix: streamFile now returns the BunFile (or its sliced view) directly and the router hands it to new Response, so Bun.serve streams it with native backpressure + sendfile.

Verified with an A/B repro (1 GB file, stalled consumer): peak RSS drops from ~1956 MB → ~39 MB, and range serving stays byte-correct (206 + correct Content-Range).

feat: widen peer download retry backoff to survive a peer outage

The download retry schedule (5 attempts, 60 s max backoff) gave up in ~3 minutes — terminally failing a resumable download long before a restarted peer recovered. Raised maxDownloadAttempts to 13 and retryMaxDelayMs to 30 min (worst-case ~64 min window, ~32 min with jitter); early retries stay ~1 s for ordinary blips. Complements this PR's per-chunk idleTimeoutMs: the idle timeout aborts a genuinely stalled transfer quickly, the backoff keeps retrying long enough to ride out a peer being down ~15–20 min, then resumes from the .part.

Deploy note: the backpressure fix is server-side — it must reach serving peers to stop the wedge.

All 176 backend tests pass; lint clean; tsc clean.

Greptile Summary

This PR replaces jack's TorrentBlackhole download client with a full qBittorrent WebUI API emulation (/api/v2/...), allowing Sonarr/Radarr to register jack as a qBittorrent client and poll it for download progress using the existing downloads table. The blackhole watcher and push-import model are removed in favour of *arr-pulled imports triggered by pausedUP state in torrentsInfo.

  • Auth & session: SID-cookie login validates the username against a configured server connector name and the password against jack.apiKey; session TTL is refreshed on every authenticated poll (matching real qBittorrent behaviour).
  • Infohash correctness: getStubInfoHash and createTorrentStub share the same buildStubInfo helper, guaranteeing the hash jack reports matches what *arr computed from the grabbed .torrent.
  • Auto-registration upgrade: registerDownloadClient now emits QBittorrentSettings and matches existing clients by name, so a prior TorrentBlackhole "Jack" client is upgraded in place without leaving duplicates; schema migration 0002 adds nullable qb_category/qb_source_server columns.

Confidence Score: 5/5

Safe to merge; the qBittorrent emulation is well-scoped and the critical infohash correctness guarantee is solid.

The new qB API layer is cleanly isolated, auth is properly session-scoped, and the infohash derivation is shared between stub creation and reporting so *arr tracking is reliable. The previously noted issues (silent 'ok' on failed adds, session TTL not refreshed on polls) are both addressed in this revision. The remaining concerns are operational (O(n) repository scans on frequent polls, no in-flight download cancellation on delete) and do not affect correctness for current deployment sizes.

The qbittorrent controller's repository.list() call on every *arr poll warrants attention as the downloads table grows over time.

Important Files Changed

Filename Overview
apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts New controller implementing qBittorrent API emulation; login, torrentsInfo, addTorrent, deleteTorrents, setCategory all well-structured, with session-scoping and 'failed'/'unavailable' surfacing to *arr. Full table scan on every poll and missing cancellation on delete are P2 concerns.
apps/backend/src/modules/qbittorrent/qbittorrent.router.ts Hono router mounting the qB API surface; SID guard via middleware on /app/* and /torrents/*; auth/login and auth/logout are correctly public. Response codes (403 not 401, 415 for magnets, 503 for unavailable) match qBittorrent conventions.
apps/backend/src/modules/qbittorrent/qbittorrent.session.ts In-memory SID store with 1h TTL; TTL is now refreshed on every get() (matching real qBittorrent), and expired entries are swept on each login. Looks correct.
apps/backend/src/modules/qbittorrent/qbittorrent.mapper.ts Maps DownloadRecord → QbTorrent; uses the real BitTorrent v1 infohash (sha1 of bencoded info dict) derived from the same buildStubInfo used by createTorrentStub, ensuring *arr's hash lookup matches.
apps/backend/src/modules/downloads/downloads.service.ts BlackholeWatcher path removed; new startQbDownload wires createDownload + runDownload for qB-added rows. triggerImport removed — all imports are now *arr-pull via torrentsInfo. Stale blackhole downloads (qbSourceServer=null) that complete via resumeStaleDownloads will not trigger an import scan, which is a known gap flagged in earlier review threads.
apps/backend/src/lib/servers/peer.ts Replaced whole-request deadline with per-chunk idle timeout (armIdle/clearIdle around each network wait); added part_oversize fast path; expectedBytes fallback to releaseSize; UnknownSizeError thrown when size is completely unknown. IdleTimeoutError is transient in retry-policy; UnknownSizeError is not, which may be intentional but is undocumented.
apps/backend/src/lib/servers/arr/base.ts registerDownloadClient switched from TorrentBlackhole to QBittorrentSettings; match-by-name logic upgrades existing 'Jack' blackhole clients in place; forceSave:true on both indexer and client registration; JACK_DOWNLOAD_CLIENT_PRIORITY hardcoded to 50 to keep Jack out of *arr's general round-robin pool.
apps/backend/src/app.ts qB router mounted before requireApiKey middleware (correct; qB uses SID cookie auth); conditional on config.jack && config.downloads && downloadsRepository; completedPath always valid inside the guard.
apps/backend/src/modules/torznab/torrent.ts Refactored to share buildStubInfo between createTorrentStub and new getStubInfoHash; ensures the reported infohash is exactly sha1(bencode(info dict)) as *arr computes from the .torrent it grabbed.
apps/backend/drizzle/0002_romantic_stryfe.sql Adds nullable qb_category and qb_source_server columns to downloads via simple ALTER TABLE statements.
apps/backend/src/lib/config.ts watchPath removed from DownloadsConfig (blackhole watcher gone); idleTimeoutMs added; maxDownloadAttempts default raised from 5→13 and retryMaxDelayMs from 1min→30min to handle peer restart windows. Existing configs silently drop the old watchPath key (Zod strips unknowns).

Sequence Diagram

sequenceDiagram
    participant Arr as Sonarr/Radarr
    participant Jack as jack (/api/v2/*)
    participant DB as downloads table
    participant Peer as Peer connector

    Arr->>Jack: "POST /api/v2/auth/login (username=serverName, password=apiKey)"
    Jack-->>Arr: Ok. + SID cookie

    Arr->>Jack: POST /api/v2/torrents/add (torrent stub or jack URL)
    Jack->>Peer: getRelease(itemId)
    Peer-->>Jack: release (filename, size)
    Jack->>DB: create row (qbCategory, qbSourceServer)
    Jack->>Peer: peerDownload() [background]
    Jack-->>Arr: Ok. 200

    loop Every 5-10 s
        Arr->>Jack: "GET /api/v2/torrents/info?category=jack-{serverId}"
        Jack->>DB: list() filter by category
        Jack-->>Arr: "[{state:downloading, progress:0.45, ...}]"
    end

    Peer-->>Jack: download complete
    Jack->>DB: "markImportQueued (status=import_queued)"

    Arr->>Jack: "GET /api/v2/torrents/info?category=jack-{serverId}"
    Jack-->>Arr: "[{state:pausedUP, progress:1.0, content_path:/completed/file.mkv}]"
    Arr->>Arr: import file from content_path

    Arr->>Jack: "POST /api/v2/torrents/delete (deleteFiles=false)"
    Jack->>DB: delete row
    Jack-->>Arr: Ok. 200
Loading

Fix All in Claude Code Fix All in Codex

Reviews (10): Last reviewed commit: "feat: widen peer download retry backoff ..." | Re-trigger Greptile

Repository owner deleted a comment from gitguardian Bot Jun 6, 2026
Comment thread apps/backend/src/modules/qbittorrent/qbittorrent.session.ts
Comment thread apps/backend/src/app.ts Outdated
@roziscoding roziscoding force-pushed the feat/qbittorrent-api-emulation branch from 2bc96d6 to 872ad6b Compare June 6, 2026 18:00
@gitguardian

gitguardian Bot commented Jun 6, 2026

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Add /api/v2 routes for qBittorrent WebUI API emulation so Sonarr/Radarr
can register jack as a qBittorrent download client. Mounts before the
global requireApiKey middleware with its own SID-cookie auth.

- POST /auth/login validates username against configured server names
- GET /app/{version,webapiVersion,preferences} return contract values
- GET /torrents/{info,categories} return empty/stub for connection test
- Stub infohash helper for Phase 2 torrent mapping
Add qb_category/qb_source_server columns (migration 0002) and the
download→qBittorrent-torrent mapper so Sonarr/Radarr see real progress.

- toQbTorrent: real stub infohash, pausedUP on completion, content_path
  != save_path, byte-based progress
- torrents/info filters by category + hashes
- torrents/properties (404 unknown) and torrents/files
- chore: untrack accidentally-committed .claude lock + e2e sqlite db
Add the qBittorrent write API and switch qB-added downloads to *arr-pull
imports (blackhole-added downloads keep the existing push).

- DownloadsService: shared createDownload core + startQbDownload; skip
  triggerImport when qbSourceServer is set (per-download pull switch)
- repository delete/setQbCategory
- torrents/add accepts only jack stubs / jack download URLs (rejects
  magnets and foreign torrents); delete/setCategory scoped to the session
- wire a single DownloadsService through getApp + the blackhole watcher
Replace the TorrentBlackhole auto-registration with a qBittorrent client
pointing at ${jack.baseUrl}/api/v2, keeping the indexer↔client binding so
only the Jack client grabs the Jack indexer's releases.

- registerDownloadClient builds a QBittorrentSettings client (host/port/
  useSsl/urlBase/username/password + per-app category field); matches an
  existing "Jack" client by name to upgrade a prior blackhole client
- best-effort setShareLimits/topPrio/setForceStart no-ops
- e2e qBittorrent-flow test (login → add → poll → import)
- torrents/add returns 503 (not 415) when the download pipeline is
  unconfigured (no downloads config) — a server misconfig, not a bad
  torrent; 415 stays for magnets/foreign torrents
- QbSessionStore sweeps expired sessions on login so abandoned SIDs
  don't accumulate (get() only evicted lazily)
…khole flow

- auto-registration e2e now asserts the qBittorrent download client
  (name "Jack", implementation QBittorrent, host/port = jack-beta)
- remove download-flow.test.ts: the blackhole-via-stack flow is
  superseded by qbittorrent-flow.test.ts (real *arr integration) and
  conflicted with it on the shared fixture; blackhole watcher logic
  stays covered by downloads-service unit tests
*arr hands grabs to jack via POST /api/v2/torrents/add (parsed in-memory),
so nothing writes to a watch folder anymore. Drop the folder-watching path
entirely for a pure qB-driven, *arr-pull download service.

- delete BlackholeWatcher; remove DownloadsService.processTorrentFile,
  the reenqueue bookkeeping, and the stub-file cleanup
- remove the now-dead push-import path: DownloadsService.triggerImport +
  the destinations dependency, and ArrServerConnector.triggerImport /
  importCommandName (qB downloads import via *arr pull)
- drop the watchPath config option (completedPath stays)
- stop constructing/starting the watcher in index.ts (resume-on-startup
  and stale-row reconciliation are retained)
- update tests, e2e setup/compose, and example config accordingly
… on downloads config

- QbSessionStore.get() now extends expiry on each successful access so an
  actively-polling *arr SID never expires mid-session (matches real qB)
- mount the /api/v2 qB API only when config.downloads is set, so the
  reported save_path is always the real completedPath (never "")
@roziscoding roziscoding force-pushed the feat/qbittorrent-api-emulation branch from 872ad6b to 65ea99a Compare June 6, 2026 18:49
The Jack indexer was registered unbound: registerDownloadClient ran with
forceSave=false, so *arr's connection test to jack's qB API at registration
time could fail and throw, leaving downloadClientId undefined — the indexer
then registered with no Download Client ("Any").

- register both the indexer and the qBittorrent client with forceSave=true,
  so the client always saves (returns its id → indexer gets bound) and the
  indexer saves even when its test query returns no results
- always run auto-registration (drop the no-peers skip); with forceSave the
  indexer is accepted with an empty catalog and starts working once peers exist
- e2e: assert the Jack indexer's downloadClientId equals the Jack qB client id
  (regression guard for the binding)

Verified against a real Radarr: in-place TorrentBlackhole→QBittorrent PUT and
top-level downloadClientId binding both persist on create and update.
*arr round-robins torrent grabs across all enabled torrent clients, so
real torrents from other indexers could be routed to jack's qB client
(which rejects them). Tag the client with a configurable label (default
"jack-internal") that no media carries, so *arr keeps those grabs out of
it; Jack grabs still arrive via the indexer's explicit downloadClientId
binding, which bypasses tag filtering.

Adds autoregister.tag to the config (set "" to disable) and an ensureTag
helper that creates the tag in each destination if missing.
Rewrite the download flow, mounts, and troubleshooting for the
qBittorrent API model (no watch folder); add commented depends_on
service_healthy examples and document autoregister.tag.
The tag approach was broken: Radarr/Sonarr filter the indexer-bound
download client by the *movie's* tags too (DownloadClientProvider builds
the tag-filtered pool before resolving the indexer's DownloadClientId),
so a "jack-internal" tag no movie carries made every grab fail with
"Indexer specified download client does not exist".

Instead, register the Jack qBittorrent client at *arr's lowest priority
(50). The general client pool only round-robins the best-priority group,
so real torrents from other indexers never land on Jack — while the
indexer→client binding still routes Jack grabs to it (resolved before
priority grouping). Tags are explicitly cleared so an upgrade heals a
client previously broken by the tag.

Reverts the autoregister.tag config/ensureTag added earlier.
* feat: verify peer downloads by size with fail-fast on unknown size

Resolve the expected size from the transfer header (Content-Length /
Content-Range total) falling back to the *arr release size, and make the
final size check unconditional so a truncated stream is no longer renamed
into place and imported as complete. When no size is known at all, fail
fast with UnknownSizeError before writing any bytes.

Adds 'release_size' as an ExpectedBytesSource (schema + migration 0003)
for accurate telemetry, and handles resume edge cases via releaseSize: a
.part larger than the file is discarded and restarted; a .part already
equal to the file is finalized without re-downloading.

* feat: replace total download timeout with a network-scoped idle timeout

The 30-min whole-request deadline both killed large active downloads and
was slow to notice a stalled connection. Replace it with an inactivity
timeout (downloads.idleTimeoutMs, default 60s) armed only around the fetch
and each read, so slow disk writes never trip it. The abort carries a
sentinel reason so only it — not a later real error — is reclassified as
a retryable IdleTimeoutError; the .part is preserved so retry resumes.

Also tears down the reader (cancel) on error and cancels the body if the
.part file can't be opened.

* fix: address review — content_range source label + reader cleanup order

- Label a 206 resume's size as 'content_range' (not 'content_length') for
  accurate telemetry; adds it to ExpectedBytesSource (migration 0004).
- Release the stream reader only after the completeness check, so the error
  path's cancel+releaseLock runs against a still-locked reader on the
  IncompleteDownloadError (retried) path instead of being a swallowed no-op.
Comment thread apps/backend/src/modules/qbittorrent/qbittorrent.controller.ts
Squash of feat/download-integrity-monitoring onto the size-based
integrity / idle-timeout work already landed via #22, so this adds only
the net-new changes on top of that branch:

Primary fix — failed qB downloads no longer silently report 'ok':
addTorrent ignored startQbDownload's result, so an unknown peer or an
unsafe peer-supplied filename created no row and was invisible to both
jack and *arr, which waited out its full stuck-download grace period.
startQbDownload now returns 'started' | 'duplicate' | 'failed'; 'failed'
propagates HTTP 503 so *arr retries promptly, while an already-active
'duplicate' stays a success so a re-add of an in-flight release is not
turned into a needless retry.

Also: bump TypeScript to 6 and resolve the resulting type errors
(getReader cast, fetch-spy typings, tsconfig types: "bun").
Comment thread apps/backend/drizzle/0003_high_makkari.sql
Comment on lines 250 to +251
repo?.markImportQueued(record.id)
// Release the startup-re-enqueue claim now that the stub is gone, so a
// later legitimate re-drop of the same filename isn't silently skipped.
// Only on success: a failed re-drive keeps its stub, so it stays claimed
// (and is re-driven on the next restart) to avoid in-session hammering.
this.reenqueued.delete(record.torrentFilename)
logger.info({ torrentFilename: record.torrentFilename, filename: record.filename }, 'Download complete, triggered import')
logger.info({ torrentFilename: record.torrentFilename, filename: record.filename }, 'Download complete')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale blackhole downloads complete without triggering import

runDownload no longer calls triggerImport, which is correct for the qBittorrent pull model where *arr polls torrentsInfo and sees pausedUP. However, resumeStaleDownloads picks up all downloading rows — including those created by the old blackhole path (where qbSourceServer is null). These rows complete and are marked import_queued, but *arr never added them via /api/v2/torrents/add so it has no record of their hash. When *arr queries torrentsInfo?category=jack-<serverId>, it filters by the category it assigned; stale blackhole downloads have qbCategory = null (rendered as category: '') and are invisible to that filter. The downloaded file sits in completedPath permanently with no import triggered.

A minimal fix is to check record.qbSourceServer in downloadWithRetry after markImportQueued: if it is null, log a prominent warning directing the operator to trigger a manual import scan, or restore the triggerImport call for that case.

Fix in Claude Code Fix in Codex

Serving a peer file via a hand-pumped Bun.file().stream() does not apply
TCP backpressure: when the consumer stalls or aborts mid-download, the
origin reads the entire file into memory as fast as it can, starving the
event loop and exhausting RAM. On a swapless host a single stalled stream
of a large file is enough to hard-freeze the process until the kernel
OOM-kills it.

Return the BunFile (or its sliced view) directly from streamFile and hand
it to new Response, so Bun.serve streams it with native backpressure and
sendfile. Verified: peak RSS under a stalled consumer drops from ~2x the
file size to flat, while range serving stays byte-correct.
A peer can be unreachable for ~15-20min (restart, tunnel hiccup). The old
schedule (5 attempts, 60s max backoff) gave up in ~3min, terminally
failing a resumable download long before the peer recovered. Raise
maxDownloadAttempts to 13 and retryMaxDelayMs to 30min so the exponential
schedule spans a worst-case ~64min window (~32min with jitter); early
retries stay ~1s for ordinary network blips. The download keeps retrying
and resumes from its .part once the peer is back.
@roziscoding roziscoding merged commit 2585106 into main Jun 7, 2026
6 checks passed
@roziscoding roziscoding deleted the feat/qbittorrent-api-emulation branch June 7, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant