fix(transmission): map status 7 to Checking, implement control methods (closes #63)
Docs Check / Markdown lint (push) Failing after 1m14s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m54s
CI / Security audit (push) Successful in 2m16s
CI / Swagger Validation & Coverage (push) Successful in 2m42s
Docs Check / Mermaid diagram parse check (push) Successful in 2m43s
CI / Tests & coverage (push) Successful in 2m55s
Docs Check / Markdown lint (push) Failing after 1m14s
Build and Push Docker Image / build (push) Successful in 1m40s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m54s
CI / Security audit (push) Successful in 2m16s
CI / Swagger Validation & Coverage (push) Successful in 2m42s
Docs Check / Mermaid diagram parse check (push) Successful in 2m43s
CI / Tests & coverage (push) Successful in 2m55s
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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<number|string>} 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<number|string>} 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<number|string>} 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;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user