feat: emulate qBittorrent API to report download progress to Sonarr/Radarr#21
Conversation
2bc96d6 to
872ad6b
Compare
️✅ 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. 🦉 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 "")
872ad6b to
65ea99a
Compare
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.
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").
| 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') |
There was a problem hiding this comment.
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.
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.
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 samedownloadstable 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
POST /api/v2/auth/loginvalidates the username against a configuredservers[]connector name and the password againstjack.apiKey, issuing a SID cookie. Mounted before the global apikey middleware; unauthenticated protected endpoints return403(qB convention).GET /api/v2/app/{version,webapiVersion,preferences}report values tuned so *arr's connection test and priority/removal checks pass (webapiVersion2.9.2).GET /api/v2/torrents/{info,properties,files}mapdownloadsrows to qB torrents: real BitTorrent infohash (so *arr keeps track), byte-based progress,pausedUPon completion, andcontent_path != save_pathso *arr imports cleanly.POST /api/v2/torrents/addaccepts only jack stubs / jack download URLs; magnets and foreign torrents are rejected (415).delete/setCategoryare scoped to the calling server's rows.qb_source_serverset) skip jack'striggerImport; blackhole-added downloads keep the existing push.QBittorrentSettings, per-appmovieCategory/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
.torrentand matchestorrents/infoby it (TorrentClientBase.cs:200). So jack reports the served stub's real infohash (sha1of the bencoded info dict from release title+size) via a sharedgetStubInfoHashintorrent.ts— notsha1(peerId:itemId), which would make *arr lose track of every download.Schema
Migration
0002adds nullableqb_category/qb_source_servercolumns todownloads.Testing
bun test apps/backend/src/); lint clean; no new type errors.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.tsis written but requires the live Docker stack (runcd e2e && bun test qbittorrent-flow).Reviewed
addreturns503(not415) on missing downloads config; session store sweeps expired SIDs on login.Out of scope
No real BitTorrent (no peer wire / seeding), no
sync/maindata, nosetPreferences. 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 OOMThe 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:
streamFilenow returns theBunFile(or its sliced view) directly and the router hands it tonew Response, soBun.servestreams 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+ correctContent-Range).feat: widen peer download retry backoff to survive a peer outageThe 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
maxDownloadAttemptsto 13 andretryMaxDelayMsto 30 min (worst-case ~64 min window, ~32 min with jitter); early retries stay ~1 s for ordinary blips. Complements this PR's per-chunkidleTimeoutMs: 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.All 176 backend tests pass; lint clean;
tscclean.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 existingdownloadstable. The blackhole watcher and push-import model are removed in favour of *arr-pulled imports triggered bypausedUPstate intorrentsInfo.jack.apiKey; session TTL is refreshed on every authenticated poll (matching real qBittorrent behaviour).getStubInfoHashandcreateTorrentStubshare the samebuildStubInfohelper, guaranteeing the hash jack reports matches what *arr computed from the grabbed.torrent.registerDownloadClientnow emitsQBittorrentSettingsand matches existing clients by name, so a prior TorrentBlackhole "Jack" client is upgraded in place without leaving duplicates; schema migration 0002 adds nullableqb_category/qb_source_servercolumns.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
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. 200Reviews (10): Last reviewed commit: "feat: widen peer download retry backoff ..." | Re-trigger Greptile