From 3d49c926dcff1530c261d5354fabdb50ad66d16d Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 28 May 2026 16:01:33 +0100 Subject: [PATCH] fix(transmission): map status 7 to Checking, implement control methods (closes #63) --- CHANGELOG.md | 1 + server/clients/TransmissionClient.js | 47 +++++++++++++++++-- tests/unit/clients/TransmissionClient.test.js | 42 ++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6e669..992bb16 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 +- **TransmissionClient Hardening (Issue #63)** — Mapped the previously-unknown Transmission RPC status code `7` to `Checking` (best-effort; the RPC spec formally documents only codes 0–6, and the historical alias `TORRENT_IS_CHECKING` corresponds to code 2), so torrents reporting code 7 are now rendered with a useful status label instead of `Unknown`. Implemented three torrent control methods on `TransmissionClient` that were previously absent: `startTorrent(id)` (resumes via `torrent-start`), `stopTorrent(id)` (pauses via `torrent-stop`), and `removeTorrent(id, deleteData = false)` (removes via `torrent-remove`, optionally also deleting local files via Transmission's `delete-local-data` flag). All three accept either a single id (numeric or hash) or an array of ids, matching the Transmission RPC contract. Documented in `extractArrInfo()` that an `arrQueueId` cannot reliably be derived from filename alone — the cross-client matching path is hash-based via `DownloadMatcher.matchTorrents()` (Issue #65), which keys on `torrent.hashString` for Transmission. Added regression tests for status code 7 and all three control methods. Resolves Gitea Issue [#63](https://git.i3omb.com/Gandalf/sofarr/issues/63). - **Frontend Build Stability (Issue #66)** — Added explanatory inline comments to `client/vite.config.js` documenting two non-standard but deliberate build settings: `build.outDir = '../public'` (the Vite bundle is emitted into the Express-served `public/` directory at the repo root rather than the Vite-default `client/dist/`) and `build.emptyOutDir = false` (required so the hand-authored static assets committed under `public/` are not wiped by every `vite build`). The comments explicitly warn that changing either setting without also updating the Express static-serve configuration in `server/app.js` and the Dockerfile copy steps will break production serving. Removed a stale, untracked `client/dist/` directory (a leftover from an earlier default-Vite build) that was harmless — both `.gitignore` and `.dockerignore` already excluded it from version control and Docker contexts — but caused recurring confusion about which `index.html` was authoritative. Verified `client/index.html` correctly references `/src/main.js` as the Vite entrypoint. Resolves Gitea Issue [#66](https://git.i3omb.com/Gandalf/sofarr/issues/66). - **Download Matching & Deduplication (Issue #65)** — `DownloadMatcher.matchTorrents()` now attempts hash-first matching for every torrent before falling back to title-substring matching. The hash lookup compares `torrent.hash` (qBittorrent, rTorrent) or `torrent.hashString` (Transmission) against each *arr queue/history record's `downloadId`, restoring deterministic matching for renamed downloads and torrents whose on-disk filename has diverged from the *arr release title. Title-substring matching is retained verbatim as a fallback so unhashed clients and legacy fixtures continue to work. After the per-torrent matching pass, the returned list is deduplicated by the composite key `(arrType, arrQueueId)`: the first matched download wins, so a single torrent that maps to N *arr queue records sharing one queue id (for example, a season pack exposed as multiple per-episode rows) produces a single dashboard card instead of N near-identical duplicates. A new integration suite at `tests/integration/download-matcher-season-pack.test.js` covers hash-first matching for qBittorrent (`hash`) and Transmission (`hashString`), the title-substring fallback path, and the deduplication step. Resolves Gitea Issue [#65](https://git.i3omb.com/Gandalf/sofarr/issues/65). - **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). diff --git a/server/clients/TransmissionClient.js b/server/clients/TransmissionClient.js index 12991e5..2943fef 100644 --- a/server/clients/TransmissionClient.js +++ b/server/clients/TransmissionClient.js @@ -113,7 +113,11 @@ class TransmissionClient extends DownloadClient { 4: 'Downloading', // TORRENT_DOWNLOAD 5: 'Queued', // TORRENT_SEED_WAIT 6: 'Seeding', // TORRENT_SEED - 7: 'Unknown' // TORRENT_IS_CHECKING (alias for 2) + // Status code 7 is undocumented in the Transmission RPC spec (which + // formally defines only 0–6). The legacy alias "TORRENT_IS_CHECKING" + // (a duplicate of code 2) is the best-effort interpretation; map it to + // `Checking` so it is rendered usefully rather than as `Unknown`. + 7: 'Checking' // TORRENT_IS_CHECKING (undocumented; treated as code 2) }; const status = statusMap[torrent.status] || 'Unknown'; @@ -160,8 +164,12 @@ class TransmissionClient extends DownloadClient { } extractArrInfo(filename) { - // Similar to SABnzbdClient, try to extract Sonarr/Radarr info - + // arrQueueId cannot be extracted from filename alone; *arr exposes that + // identifier only via its queue API. The reliable cross-client matching + // path is hash-based and lives in `DownloadMatcher.matchTorrents()` (see + // Issue #65), which keys on `torrent.hashString` for Transmission. + // This heuristic remains only to provide a coarse `type` hint. + // Look for patterns like "Series Name - S01E02 - Episode Title" const seriesMatch = filename.match(/[-\s]S(\d{2})E(\d{2})/i); if (seriesMatch) { @@ -176,6 +184,39 @@ class TransmissionClient extends DownloadClient { return {}; } + + /** + * Start (resume) one or more torrents. `id` is the Transmission internal + * numeric id or a hashString; the RPC accepts either. + * @param {number|string|Array} id + */ + async startTorrent(id) { + const ids = Array.isArray(id) ? id : [id]; + await this.makeRequest('torrent-start', { ids }); + logToFile(`[Transmission:${this.name}] Started torrent(s): ${ids.join(', ')}`); + } + + /** + * Stop (pause) one or more torrents. + * @param {number|string|Array} id + */ + async stopTorrent(id) { + const ids = Array.isArray(id) ? id : [id]; + await this.makeRequest('torrent-stop', { ids }); + logToFile(`[Transmission:${this.name}] Stopped torrent(s): ${ids.join(', ')}`); + } + + /** + * Remove one or more torrents. When `deleteData` is true the local files + * are also deleted from disk (Transmission's `delete-local-data`). + * @param {number|string|Array} id + * @param {boolean} [deleteData=false] + */ + async removeTorrent(id, deleteData = false) { + const ids = Array.isArray(id) ? id : [id]; + await this.makeRequest('torrent-remove', { ids, 'delete-local-data': !!deleteData }); + logToFile(`[Transmission:${this.name}] Removed torrent(s): ${ids.join(', ')} (deleteData=${!!deleteData})`); + } } module.exports = TransmissionClient; diff --git a/tests/unit/clients/TransmissionClient.test.js b/tests/unit/clients/TransmissionClient.test.js index 88870fa..f159dcb 100644 --- a/tests/unit/clients/TransmissionClient.test.js +++ b/tests/unit/clients/TransmissionClient.test.js @@ -154,7 +154,9 @@ describe('TransmissionClient', () => { 4: 'Downloading', 5: 'Queued', 6: 'Seeding', - 7: 'Unknown' + // Issue #63: code 7 is undocumented in the RPC spec; mapped to + // `Checking` (legacy alias for code 2) as a best-effort interpretation. + 7: 'Checking' }; Object.entries(statusMap).forEach(([status, expectedStatus]) => { @@ -433,4 +435,42 @@ describe('TransmissionClient', () => { expect(normalized.arrType).toBeUndefined(); }); }); + + describe('Torrent Control Methods (Issue #63)', () => { + it('startTorrent invokes torrent-start RPC with ids array', async () => { + client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } }); + await client.startTorrent('abc123'); + expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: ['abc123'] }); + }); + + it('startTorrent accepts an array of ids', async () => { + client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } }); + await client.startTorrent([1, 2, 3]); + expect(client.makeRequest).toHaveBeenCalledWith('torrent-start', { ids: [1, 2, 3] }); + }); + + it('stopTorrent invokes torrent-stop RPC', async () => { + client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } }); + await client.stopTorrent(42); + expect(client.makeRequest).toHaveBeenCalledWith('torrent-stop', { ids: [42] }); + }); + + it('removeTorrent invokes torrent-remove with delete-local-data=false by default', async () => { + client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } }); + await client.removeTorrent('hashX'); + expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', { + ids: ['hashX'], + 'delete-local-data': false + }); + }); + + it('removeTorrent passes delete-local-data=true when requested', async () => { + client.makeRequest = vi.fn().mockResolvedValue({ data: { result: 'success' } }); + await client.removeTorrent('hashY', true); + expect(client.makeRequest).toHaveBeenCalledWith('torrent-remove', { + ids: ['hashY'], + 'delete-local-data': true + }); + }); + }); });