Skip to content

feat(client): add ability to set Transmission as the download client#6

Open
SplinterHead wants to merge 2 commits into
Yakrel:mainfrom
SplinterHead:feat/transmission-support
Open

feat(client): add ability to set Transmission as the download client#6
SplinterHead wants to merge 2 commits into
Yakrel:mainfrom
SplinterHead:feat/transmission-support

Conversation

@SplinterHead

@SplinterHead SplinterHead commented Jun 23, 2026

Copy link
Copy Markdown
  • 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

Successfully connects

Screenshot 2026-06-23 at 14 53 33

Tracks in-progress downloads

Screenshot 2026-06-23 at 14 54 07

* 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +6 to +11
import {
extractVersion,
sanitizeSearchQuery,
fuzzyMatchTitles,
parseTorrentTitle
} from './utils.js';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Statically import cleanGameTitle from ./utils.js and getGameMetadata from ./igdb.js to avoid dynamic imports inside addNewGame.

Suggested change
import {
extractVersion,
sanitizeSearchQuery,
fuzzyMatchTitles,
parseTorrentTitle
} from './utils.js';
import {
extractVersion,
sanitizeSearchQuery,
fuzzyMatchTitles,
parseTorrentTitle,
cleanGameTitle
} from './utils.js';
import { getGameMetadata } from './igdb.js';

Comment thread src/lib/server/transmission.ts Outdated
Comment on lines +107 to +108
dl_limit: (t.downloadLimit || 0) * 1024,
up_limit: (t.uploadLimit || 0) * 1024,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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,

Comment on lines +121 to +128
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 [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add "downloadLimited" and "uploadLimited" to the fields retrieved from Transmission RPC so that mapTorrent can correctly determine if the speed limits are active.

Suggested change
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 [];

Comment thread src/lib/server/transmission.ts Outdated
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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime errors if t.labels is not an array (or is undefined), use Array.isArray(t.labels) before calling includes.

Suggested change
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));

Comment on lines +144 to +151
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"
]
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add "downloadLimited" and "uploadLimited" to the fields retrieved from Transmission RPC so that mapTorrent can correctly determine if the speed limits are active.

Suggested change
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"
]
});

Comment on lines +214 to +225
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 {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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 {}
		}

Comment on lines +261 to +263
if (this.defaultDownloadDir) {
downloadDir = `${this.defaultDownloadDir}/${targetLabel}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Strip any trailing slash from this.defaultDownloadDir to prevent double slashes in the path (e.g., /downloads//games).

Suggested change
if (this.defaultDownloadDir) {
downloadDir = `${this.defaultDownloadDir}/${targetLabel}`;
}
if (this.defaultDownloadDir) {
downloadDir = `${this.defaultDownloadDir.replace(/\/$/, '')}/${targetLabel}`;
}

Comment thread src/lib/server/transmission.ts Outdated

async setTorrentDownloadLimit(hash: string, limitBytesPerSec: number): Promise<boolean> {
if (!hash || !(await this.login())) return false;
const limitKBps = Math.floor(limitBytesPerSec / 1024);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Suggested change
const limitKBps = Math.floor(limitBytesPerSec / 1024);
const limitKBps = Math.round(limitBytesPerSec / 1024);

Comment thread src/lib/server/transmission.ts Outdated

async setTorrentUploadLimit(hash: string, limitBytesPerSec: number): Promise<boolean> {
if (!hash || !(await this.login())) return false;
const limitKBps = Math.floor(limitBytesPerSec / 1024);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use Math.round instead of Math.floor to get the closest integer value when converting bytes per second to KB/s.

Suggested change
const limitKBps = Math.floor(limitBytesPerSec / 1024);
const limitKBps = Math.round(limitBytesPerSec / 1024);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant