From 498eabc7bccfa3584ea2ee8c13c294f7662e8a24 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 28 May 2026 15:52:52 +0100 Subject: [PATCH] fix(qbittorrent): add seeds/peers fields (num_seeds/num_leechs), guard empty response (closes #64) --- CHANGELOG.md | 1 + server/clients/QBittorrentClient.js | 13 +++++++++++ tests/unit/clients/QBittorrentClient.test.js | 24 ++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c872f82..6bb4583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed +- **qBittorrentClient Peer Data & Response Safety (Issue #64)** — `QBittorrentClient.normalizeDownload()` now exposes two new fields on every torrent record: `seeds` (sourced from qBittorrent's `num_seeds`, the count of connected seed peers) and `peers` (sourced from `num_leechs`, the count of connected leecher peers). The connected counts were chosen deliberately over the swarm totals `num_complete`/`num_incomplete` so the values remain consistent with what other clients (Transmission via `peersConnected`/`peersSendingToUs`, rTorrent via `d.peers_connected`) report on the same normalised contract. `QBittorrentClient.getMainData()` now also defensively returns the existing in-memory torrent map (rather than dereferencing a null) when the qBittorrent server responds with an empty body to `/api/v2/sync/maindata`, eliminating a crash class observed against transiently-restarting qBittorrent instances. A regression test verifies the new fields are populated from `num_seeds`/`num_leechs` and not from the swarm-total fields. Resolves Gitea Issue [#64](https://git.i3omb.com/Gandalf/sofarr/issues/64). - **Season Pack Queue Handling & Crash Prevention (Issue #61)** — Extracted a shared `buildArrQueueCache(queues, instances, mediaKey)` helper at `server/utils/arrQueueHelpers.js` covering both Sonarr and Radarr, replacing four previously-divergent inline `flatMap` blocks across the background poller (`server/utils/poller.js`) and the webhook event processor (`server/routes/webhook.js`) that built the `poll:sonarr-queue` and `poll:radarr-queue` cache entries. Sonarr queue records that share a `downloadId` (the canonical fingerprint for a season-pack release) are now annotated with `isSeasonPack: true` and `episodeCount: ` so downstream consumers — including the active-downloads matching service — can identify and de-duplicate season packs without re-deriving the grouping. The helper is wrapped in per-record and per-instance `try`/`catch` guards: malformed records (`null`, missing `data`, unknown instance ids) are skipped with a warning rather than throwing, eliminating a class of crashes that previously bubbled out of the `flatMap` and tore down the entire poll cycle or webhook refresh. Movies (Radarr) skip season-pack annotation by design. A new unit test suite at `tests/unit/utils/arrQueueHelpers.test.js` covers tagging, season-pack grouping, null-safety, and unknown-instance fallback. Resolves Gitea Issue [#61](https://git.i3omb.com/Gandalf/sofarr/issues/61). - **Webhook Reliability (Issue #62)** — Hardened the webhook replay protection to prevent false-duplicate detection while preserving protection against genuine retries. The replay key for Sonarr and Radarr now incorporates a content identifier (`downloadId`, falling back to `series.id` or `movie.id`) alongside the existing `eventType:instanceName:eventDate` components, so that multiple distinct events sharing the same timestamp (for example, several `Grab` events fired in the same second for episodes in a season pack) no longer collide and get silently dropped. Events without a content identifier (such as `Test`) fall back gracefully to the previous key shape so existing behaviour is preserved. The Ombi handler — which already uses a distinct `requestId`-bearing key — is unchanged. Additionally, the Sonarr and Radarr handlers now log an explicit warning when the inbound `instanceName` fails to match any configured instance and processing falls back to the first instance, improving diagnosability of misconfigured webhook senders. Resolves Gitea Issue [#62](https://git.i3omb.com/Gandalf/sofarr/issues/62). diff --git a/server/clients/QBittorrentClient.js b/server/clients/QBittorrentClient.js index 7fdf876..79c6aa9 100644 --- a/server/clients/QBittorrentClient.js +++ b/server/clients/QBittorrentClient.js @@ -104,6 +104,11 @@ class QBittorrentClient extends DownloadClient { const response = await this.makeRequest(`/api/v2/sync/maindata?rid=${this.lastRid}`); const data = response.data; + if (!data) { + logToFile(`[qBittorrent:${this.name}] Empty response from sync/maindata`); + return Array.from(this.torrentMap.values()); + } + if (data.full_update) { // Full refresh: rebuild the entire map this.torrentMap.clear(); @@ -249,6 +254,14 @@ class QBittorrentClient extends DownloadClient { downloaded: downloadedSize, speed: torrent.dlspeed, eta: torrent.eta < 0 || torrent.eta === 8640000 ? null : torrent.eta, + // Connected peer counts (Issue #64). qBittorrent exposes: + // num_seeds — connected seeds (peers we have a connection to) + // num_leechs — connected leechers (peers downloading from us) + // num_complete / num_incomplete — *swarm* totals reported by tracker + // We expose the connected counts to stay consistent with what other + // clients (e.g. Transmission via peersConnected/peersSendingToUs) report. + seeds: torrent.num_seeds ?? 0, + peers: torrent.num_leechs ?? 0, category: torrent.category || undefined, tags: torrent.tags ? torrent.tags.split(',').filter(tag => tag.trim()) : [], savePath: torrent.content_path || torrent.save_path || undefined, diff --git a/tests/unit/clients/QBittorrentClient.test.js b/tests/unit/clients/QBittorrentClient.test.js index 4c845c9..da0ce4c 100644 --- a/tests/unit/clients/QBittorrentClient.test.js +++ b/tests/unit/clients/QBittorrentClient.test.js @@ -130,6 +130,8 @@ describe('QBittorrentClient', () => { downloaded: 750000000, speed: 1048576, eta: 3600, + seeds: 0, + peers: 0, category: 'movies', tags: ['movie', 'hd'], savePath: '/downloads/test', @@ -138,6 +140,28 @@ describe('QBittorrentClient', () => { }); }); + it('should expose connected seeds/peers from num_seeds and num_leechs (Issue #64)', () => { + const torrent = { + hash: 'def456', + name: 'Swarm Torrent', + state: 'downloading', + progress: 0.1, + size: 1000, + completed: 100, + dlspeed: 0, + eta: 0, + num_seeds: 7, + num_leechs: 3, + // Swarm totals — must NOT be picked up as connected counts + num_complete: 200, + num_incomplete: 50 + }; + + const normalized = client.normalizeDownload(torrent); + expect(normalized.seeds).toBe(7); + expect(normalized.peers).toBe(3); + }); + it('should handle unknown torrent states', () => { const torrent = { hash: 'abc123',