feat(client): add ability to set Transmission as the download client#6
feat(client): add ability to set Transmission as the download client#6SplinterHead wants to merge 2 commits into
Conversation
* Uses current .env var to set * Sanity check that the user is not setting both clients simultaneously * Abstracts interaction to a torrentClient to allow switching client type
There was a problem hiding this comment.
Code Review
This pull request introduces support for Transmission as an alternative torrent client to qBittorrent by abstracting client interactions into a unified torrentClient interface. It adds a new transmission.ts service, updates configuration schemas, and refactors existing server logic and UI components to support both clients. The review feedback highlights several key improvements for the new Transmission service, including replacing dynamic imports with static imports, correctly querying and checking the downloadLimited and uploadLimited flags to report active speed limits, safely validating that labels are arrays before checking their contents, stripping trailing slashes from the default download directory, and using Math.round instead of Math.floor to prevent rounding errors when converting speed limits to KB/s.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| import { | ||
| extractVersion, | ||
| sanitizeSearchQuery, | ||
| fuzzyMatchTitles, | ||
| parseTorrentTitle | ||
| } from './utils.js'; |
There was a problem hiding this comment.
Statically import cleanGameTitle from ./utils.js and getGameMetadata from ./igdb.js to avoid dynamic imports inside addNewGame.
| import { | |
| extractVersion, | |
| sanitizeSearchQuery, | |
| fuzzyMatchTitles, | |
| parseTorrentTitle | |
| } from './utils.js'; | |
| import { | |
| extractVersion, | |
| sanitizeSearchQuery, | |
| fuzzyMatchTitles, | |
| parseTorrentTitle, | |
| cleanGameTitle | |
| } from './utils.js'; | |
| import { getGameMetadata } from './igdb.js'; |
| dl_limit: (t.downloadLimit || 0) * 1024, | ||
| up_limit: (t.uploadLimit || 0) * 1024, |
There was a problem hiding this comment.
In Transmission, a torrent can have a downloadLimit or uploadLimit set even if the limit is currently disabled (i.e., downloadLimited or uploadLimited is false). To correctly report the active limits as 0 (unlimited) when they are disabled, we should check these boolean flags. Note that you will also need to add "downloadLimited" and "uploadLimited" to the RPC fields in fetchTorrents and getTorrent.
| dl_limit: (t.downloadLimit || 0) * 1024, | |
| up_limit: (t.uploadLimit || 0) * 1024, | |
| dl_limit: t.downloadLimited ? (t.downloadLimit || 0) * 1024 : 0, | |
| up_limit: t.uploadLimited ? (t.uploadLimit || 0) * 1024 : 0, |
| const args = await this.rpcCall('torrent-get', { | ||
| fields: [ | ||
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | ||
| "downloadLimit", "uploadLimit", "eta", "labels", "addedDate", "doneDate", | ||
| "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | ||
| ] | ||
| }); | ||
| if (!args || !args.torrents) return []; |
There was a problem hiding this comment.
Add "downloadLimited" and "uploadLimited" to the fields retrieved from Transmission RPC so that mapTorrent can correctly determine if the speed limits are active.
| const args = await this.rpcCall('torrent-get', { | |
| fields: [ | |
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | |
| "downloadLimit", "uploadLimit", "eta", "labels", "addedDate", "doneDate", | |
| "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | |
| ] | |
| }); | |
| if (!args || !args.torrents) return []; | |
| const args = await this.rpcCall('torrent-get', { | |
| fields: [ | |
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | |
| "downloadLimit", "uploadLimit", "downloadLimited", "uploadLimited", "eta", "labels", | |
| "addedDate", "doneDate", "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | |
| ] | |
| }); | |
| if (!args || !args.torrents) return []; |
| const allTorrents = args.torrents; | ||
| // Filter by label | ||
| const targetLabel = settings.TRANSMISSION_LABEL || 'games'; | ||
| const filtered = allTorrents.filter((t: any) => t.labels && t.labels.includes(targetLabel)); |
There was a problem hiding this comment.
To prevent potential runtime errors if t.labels is not an array (or is undefined), use Array.isArray(t.labels) before calling includes.
| const filtered = allTorrents.filter((t: any) => t.labels && t.labels.includes(targetLabel)); | |
| const filtered = allTorrents.filter((t: any) => Array.isArray(t.labels) && t.labels.includes(targetLabel)); |
| const args = await this.rpcCall('torrent-get', { | ||
| ids: [hash], | ||
| fields: [ | ||
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | ||
| "downloadLimit", "uploadLimit", "eta", "labels", "addedDate", "doneDate", | ||
| "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | ||
| ] | ||
| }); |
There was a problem hiding this comment.
Add "downloadLimited" and "uploadLimited" to the fields retrieved from Transmission RPC so that mapTorrent can correctly determine if the speed limits are active.
| const args = await this.rpcCall('torrent-get', { | |
| ids: [hash], | |
| fields: [ | |
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | |
| "downloadLimit", "uploadLimit", "eta", "labels", "addedDate", "doneDate", | |
| "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | |
| ] | |
| }); | |
| const args = await this.rpcCall('torrent-get', { | |
| ids: [hash], | |
| fields: [ | |
| "id", "name", "hashString", "status", "percentDone", "rateDownload", "rateUpload", | |
| "downloadLimit", "uploadLimit", "downloadLimited", "uploadLimited", "eta", "labels", | |
| "addedDate", "doneDate", "totalSize", "peersSendingToUs", "peersGettingFromUs", "comment" | |
| ] | |
| }); |
| if (isIgdbEnabled()) { | ||
| try { | ||
| const { getGameMetadata } = await import('./igdb.js'); | ||
| const { cleanGameTitle } = await import('./utils.js'); | ||
| const meta = await getGameMetadata(cleanGameTitle(title)); | ||
| if (meta) { | ||
| coverUrl = meta.coverUrl || null; | ||
| steamAppId = meta.steamAppId || null; | ||
| igdbId = meta.igdbId || null; | ||
| igdbName = meta.name; | ||
| } | ||
| } catch {} |
There was a problem hiding this comment.
There is no circular dependency or other reason to use dynamic imports here. Statically importing getGameMetadata from ./igdb.js and cleanGameTitle from ./utils.js at the top of the file is cleaner, more standard, and avoids runtime import overhead.
if (isIgdbEnabled()) {
try {
const meta = await getGameMetadata(cleanGameTitle(title));
if (meta) {
coverUrl = meta.coverUrl || null;
steamAppId = meta.steamAppId || null;
igdbId = meta.igdbId || null;
igdbName = meta.name;
}
} catch {}
}| if (this.defaultDownloadDir) { | ||
| downloadDir = `${this.defaultDownloadDir}/${targetLabel}`; | ||
| } |
There was a problem hiding this comment.
Strip any trailing slash from this.defaultDownloadDir to prevent double slashes in the path (e.g., /downloads//games).
| if (this.defaultDownloadDir) { | |
| downloadDir = `${this.defaultDownloadDir}/${targetLabel}`; | |
| } | |
| if (this.defaultDownloadDir) { | |
| downloadDir = `${this.defaultDownloadDir.replace(/\/$/, '')}/${targetLabel}`; | |
| } |
|
|
||
| async setTorrentDownloadLimit(hash: string, limitBytesPerSec: number): Promise<boolean> { | ||
| if (!hash || !(await this.login())) return false; | ||
| const limitKBps = Math.floor(limitBytesPerSec / 1024); |
There was a problem hiding this comment.
Use Math.round instead of Math.floor to get the closest integer value when converting bytes per second to KB/s. This prevents a limit like 1023 bytes/sec from rounding down to 0 KB/s (which disables the limit).
| const limitKBps = Math.floor(limitBytesPerSec / 1024); | |
| const limitKBps = Math.round(limitBytesPerSec / 1024); |
|
|
||
| async setTorrentUploadLimit(hash: string, limitBytesPerSec: number): Promise<boolean> { | ||
| if (!hash || !(await this.login())) return false; | ||
| const limitKBps = Math.floor(limitBytesPerSec / 1024); |
Successfully connects
Tracks in-progress downloads