diff --git a/ABOUT.md b/ABOUT.md
index 4e637cff..e568ab63 100644
--- a/ABOUT.md
+++ b/ABOUT.md
@@ -6,12 +6,17 @@
Flood is a monitoring service for various torrent clients. It's a Node.js service that communicates with your favorite torrent client and serves a decent web UI for administration. This project is based on the [original Flood project](https://github.com/Flood-UI/flood).
-Flood currently provides stable and tested support to [rTorrent](https://github.com/rakshasa/rtorrent) and experimental support to [qBittorrent](https://github.com/qbittorrent/qBittorrent).
+#### Supported Clients
+| Client | Support |
+|--------------------------------------------------------------|--------------------------------------|
+| [rTorrent](https://github.com/rakshasa/rtorrent) | Stable and Tested :white_check_mark: |
+| [qBittorrent](https://github.com/qbittorrent/qBittorrent) | Experimental :alembic: |
+| [Transmission](https://github.com/transmission/transmission) | Experimental :alembic: |
#### Feedback
If you have a specific issue or bug, please file a [GitHub issue](https://github.com/jesec/flood/issues). Please join the [Flood Discord server](https://discord.gg/Z7yR5Uf) to discuss feature requests and implementation details.
-#### More information
+#### More Information
Check out the [Wiki](https://github.com/jesec/flood/wiki) for more information.
diff --git a/README.md b/README.md
index 1b8e6565..58a17cee 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Flood - [flood.js.org](https://flood.js.org)
+# Flood
[](https://flood.js.org)
@@ -6,13 +6,18 @@
Flood is a monitoring service for various torrent clients. It's a Node.js service that communicates with your favorite torrent client and serves a decent web UI for administration. This project is based on the [original Flood project](https://github.com/Flood-UI/flood).
-Flood currently provides stable and tested support to [rTorrent](https://github.com/rakshasa/rtorrent) and experimental support to [qBittorrent](https://github.com/qbittorrent/qBittorrent).
+#### Supported Clients
+| Client | Support |
+|--------------------------------------------------------------|--------------------------------------|
+| [rTorrent](https://github.com/rakshasa/rtorrent) | Stable and Tested :white_check_mark: |
+| [qBittorrent](https://github.com/qbittorrent/qBittorrent) | Experimental :alembic: |
+| [Transmission](https://github.com/transmission/transmission) | Experimental :alembic: |
#### Feedback
If you have a specific issue or bug, please file a [GitHub issue](https://github.com/jesec/flood/issues). Please join the [Flood Discord server](https://discord.gg/Z7yR5Uf) to discuss feature requests and implementation details.
-#### More information
+#### More Information
Check out the [Wiki](https://github.com/jesec/flood/wiki) for more information.
diff --git a/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
index 49bcecc1..e44d0e03 100644
--- a/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
+++ b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
@@ -7,6 +7,7 @@ import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSett
import QBittorrentConnectionSettingsForm from './QBittorrentConnectionSettingsForm';
import RTorrentConnectionSettingsForm from './RTorrentConnectionSettingsForm';
+import TransmissionConnectionSettingsForm from './TransmissionConnectionSettingsForm';
import {FormRow, Select, SelectItem} from '../../../ui';
const DEFAULT_SELECTION: ClientConnectionSettings['client'] = 'rTorrent' as const;
@@ -21,7 +22,10 @@ const getClientSelectItems = (): React.ReactNodeArray => {
});
};
-type ConnectionSettingsForm = QBittorrentConnectionSettingsForm | RTorrentConnectionSettingsForm;
+type ConnectionSettingsForm =
+ | QBittorrentConnectionSettingsForm
+ | RTorrentConnectionSettingsForm
+ | TransmissionConnectionSettingsForm;
interface ClientConnectionSettingsFormStates {
client: ClientConnectionSettings['client'];
@@ -60,6 +64,9 @@ class ClientConnectionSettingsForm extends React.Component;
break;
+ case 'Transmission':
+ settingsForm = ;
+ break;
default:
break;
}
diff --git a/client/src/javascript/components/general/connection-settings/TransmissionConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/TransmissionConnectionSettingsForm.tsx
new file mode 100644
index 00000000..4e9cafcd
--- /dev/null
+++ b/client/src/javascript/components/general/connection-settings/TransmissionConnectionSettingsForm.tsx
@@ -0,0 +1,112 @@
+import {FormattedMessage, IntlShape} from 'react-intl';
+import * as React from 'react';
+
+import type {TransmissionConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+
+import {FormGroup, FormRow, Textbox} from '../../../ui';
+
+export interface TransmissionConnectionSettingsProps {
+ intl: IntlShape;
+}
+
+export interface TransmissionConnectionSettingsFormData {
+ url: string;
+ username: string;
+ password: string;
+}
+
+class TransmissionConnectionSettingsForm extends React.Component<
+ TransmissionConnectionSettingsProps,
+ TransmissionConnectionSettingsFormData
+> {
+ constructor(props: TransmissionConnectionSettingsProps) {
+ super(props);
+
+ this.state = {
+ url: '',
+ username: '',
+ password: '',
+ };
+ }
+
+ getConnectionSettings = (): TransmissionConnectionSettings | null => {
+ if (this.state.url == null || this.state.url === '') {
+ return null;
+ }
+
+ const settings: TransmissionConnectionSettings = {
+ client: 'Transmission',
+ type: 'rpc',
+ version: 1,
+ url: this.state.url,
+ username: this.state.username || '',
+ password: this.state.password || '',
+ };
+
+ return settings;
+ };
+
+ handleFormChange = (
+ event: React.MouseEvent | KeyboardEvent | React.ChangeEvent,
+ field: keyof TransmissionConnectionSettingsFormData,
+ ): void => {
+ const inputElement = event.target as HTMLInputElement;
+
+ if (inputElement == null) {
+ return;
+ }
+
+ const {value} = inputElement;
+
+ if (this.state[field] !== value) {
+ this.setState((prev) => {
+ return {
+ ...prev,
+ [field]: value,
+ };
+ });
+ }
+ };
+
+ render() {
+ return (
+
+
+
+ this.handleFormChange(e, 'url')}
+ id="url"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.transmission.url.input.placeholder',
+ })}
+ />
+
+
+ this.handleFormChange(e, 'username')}
+ id="transmission-username"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.transmission.username.input.placeholder',
+ })}
+ autoComplete="off"
+ />
+ this.handleFormChange(e, 'password')}
+ id="transmission-password"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.transmission.password.input.placeholder',
+ })}
+ autoComplete="off"
+ type="password"
+ />
+
+
+
+ );
+ }
+}
+
+export default TransmissionConnectionSettingsForm;
diff --git a/client/src/javascript/i18n/strings.compiled.json b/client/src/javascript/i18n/strings.compiled.json
index b858ac42..72706c18 100644
--- a/client/src/javascript/i18n/strings.compiled.json
+++ b/client/src/javascript/i18n/strings.compiled.json
@@ -587,6 +587,48 @@
"value": "Exposing rTorrent via TCP may allow privilege escalation."
}
],
+ "connection.settings.transmission": [
+ {
+ "type": 0,
+ "value": "Transmission"
+ }
+ ],
+ "connection.settings.transmission.password": [
+ {
+ "type": 0,
+ "value": "Password"
+ }
+ ],
+ "connection.settings.transmission.password.input.placeholder": [
+ {
+ "type": 0,
+ "value": "Password"
+ }
+ ],
+ "connection.settings.transmission.url": [
+ {
+ "type": 0,
+ "value": "URL"
+ }
+ ],
+ "connection.settings.transmission.url.input.placeholder": [
+ {
+ "type": 0,
+ "value": "URL to Transmission RPC interface"
+ }
+ ],
+ "connection.settings.transmission.username": [
+ {
+ "type": 0,
+ "value": "Username"
+ }
+ ],
+ "connection.settings.transmission.username.input.placeholder": [
+ {
+ "type": 0,
+ "value": "Username"
+ }
+ ],
"connectivity.modal.content": [
{
"type": 0,
diff --git a/client/src/javascript/i18n/strings.json b/client/src/javascript/i18n/strings.json
index 125fb2bd..7fdf07b7 100644
--- a/client/src/javascript/i18n/strings.json
+++ b/client/src/javascript/i18n/strings.json
@@ -60,6 +60,13 @@
"connection.settings.qbittorrent.username.input.placeholder": "Username",
"connection.settings.qbittorrent.password": "Password",
"connection.settings.qbittorrent.password.input.placeholder": "Password",
+ "connection.settings.transmission": "Transmission",
+ "connection.settings.transmission.url": "URL",
+ "connection.settings.transmission.url.input.placeholder": "URL to Transmission RPC interface",
+ "connection.settings.transmission.username": "Username",
+ "connection.settings.transmission.username.input.placeholder": "Username",
+ "connection.settings.transmission.password": "Password",
+ "connection.settings.transmission.password.input.placeholder": "Password",
"connectivity.modal.title": "Connectivity Issue",
"connectivity.modal.content": "Cannot connect to the client. Please update connection settings.",
"feeds.add.automatic.download.rule": "Add Download Rule",
diff --git a/config.cli.js b/config.cli.js
index b62ad909..af95881f 100644
--- a/config.cli.js
+++ b/config.cli.js
@@ -70,7 +70,19 @@ const {argv} = require('yargs')
describe: 'Password of qBittorrent Web API',
type: 'string',
})
- .group(['rthost', 'rtport', 'rtsocket', 'qburl', 'qbuser', 'qbpass'], 'When auth=none:')
+ .option('trurl', {
+ describe: 'URL to Transmission RPC interface',
+ type: 'string',
+ })
+ .option('truser', {
+ describe: 'Username of Transmission RPC interface',
+ type: 'string',
+ })
+ .option('trpass', {
+ describe: 'Password of Transmission RPC interface',
+ type: 'string',
+ })
+ .group(['rthost', 'rtport', 'rtsocket', 'qburl', 'qbuser', 'qbpass', 'trurl', 'truser', 'trpass'], 'When auth=none:')
.option('ssl', {
default: false,
describe: 'Enable SSL, key.pem and fullchain.pem needed in runtime directory',
@@ -200,6 +212,15 @@ if (argv.rtsocket != null || argv.rthost != null) {
username: argv.qbuser,
password: argv.qbpass,
};
+} else if (argv.trurl != null) {
+ connectionSettings = {
+ client: 'Transmission',
+ type: 'rpc',
+ version: 1,
+ url: argv.trurl,
+ username: argv.truser,
+ password: argv.trpass,
+ };
}
let authMethod = 'default';
diff --git a/package.json b/package.json
index d485e736..68f75e92 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,8 @@
"webui",
"web ui",
"rTorrent",
- "qBittorrent"
+ "qBittorrent",
+ "Transmission"
],
"private": false,
"license": "GPL-3.0-only",
diff --git a/server/services/Transmission/clientGatewayService.ts b/server/services/Transmission/clientGatewayService.ts
new file mode 100644
index 00000000..06232682
--- /dev/null
+++ b/server/services/Transmission/clientGatewayService.ts
@@ -0,0 +1,480 @@
+import geoip from 'geoip-country';
+
+import type {ClientSettings} from '@shared/types/ClientSettings';
+import type {TorrentContent} from '@shared/types/TorrentContent';
+import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent';
+import type {TorrentPeer} from '@shared/types/TorrentPeer';
+import type {TorrentTracker} from '@shared/types/TorrentTracker';
+import type {TransferSummary} from '@shared/types/TransferData';
+import type {TransmissionConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+import type {
+ AddTorrentByFileOptions,
+ AddTorrentByURLOptions,
+ CheckTorrentsOptions,
+ DeleteTorrentsOptions,
+ MoveTorrentsOptions,
+ SetTorrentContentsPropertiesOptions,
+ SetTorrentsPriorityOptions,
+ SetTorrentsTagsOptions,
+ SetTorrentsTrackersOptions,
+ StartTorrentsOptions,
+ StopTorrentsOptions,
+} from '@shared/types/api/torrents';
+import type {SetClientSettingsOptions} from '@shared/types/api/client';
+
+import ClientGatewayService from '../interfaces/clientGatewayService';
+import ClientRequestManager from './clientRequestManager';
+import {getDomainsFromURLs} from '../../util/torrentPropertiesUtil';
+import {TorrentContentPriority} from '../../../shared/types/TorrentContent';
+import {TorrentPriority} from '../../../shared/types/Torrent';
+import torrentPropertiesUtil from './util/torrentPropertiesUtil';
+import {TorrentTrackerType} from '../../../shared/types/TorrentTracker';
+import {TransmissionPriority, TransmissionTorrentsSetArguments} from './types/TransmissionTorrentsMethods';
+
+class TransmissionClientGatewayService extends ClientGatewayService {
+ clientRequestManager = new ClientRequestManager(this.user.client as TransmissionConnectionSettings);
+
+ async addTorrentsByFile({files, destination, tags, start}: AddTorrentByFileOptions): Promise {
+ const addedTorrents: Array = (
+ await Promise.all(
+ files.map(async (file) => {
+ const {hashString} =
+ (await this.clientRequestManager
+ .addTorrent({metainfo: file, 'download-dir': destination, paused: !start})
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .catch(() => undefined)) || {};
+ return hashString;
+ }),
+ )
+ ).filter((hash) => hash != null) as Array;
+
+ if (tags?.length) {
+ await this.setTorrentsTags({hashes: addedTorrents, tags});
+ }
+ }
+
+ async addTorrentsByURL({urls, cookies, destination, tags, start}: AddTorrentByURLOptions): Promise {
+ const addedTorrents: Array = (
+ await Promise.all(
+ urls.map(async (url) => {
+ const domain = url.split('/')[2];
+ const {hashString} =
+ (await this.clientRequestManager
+ .addTorrent({
+ filename: url,
+ cookies: cookies?.[domain] != null ? `${cookies[domain].join('; ')};` : undefined,
+ 'download-dir': destination,
+ paused: !start,
+ })
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .catch(() => undefined)) || {};
+ return hashString;
+ }),
+ )
+ ).filter((hash) => hash != null) as Array;
+
+ if (tags?.length) {
+ await this.setTorrentsTags({hashes: addedTorrents, tags});
+ }
+ }
+
+ async checkTorrents({hashes}: CheckTorrentsOptions): Promise {
+ return this.clientRequestManager
+ .verifyTorrents(hashes)
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async getTorrentContents(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager
+ .getTorrents(hash, ['files', 'fileStats'])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((torrents) => {
+ const [torrent] = torrents;
+ if (torrent == null) {
+ return Promise.reject();
+ }
+
+ const {files, fileStats} = torrent;
+ if (files.length !== fileStats.length) {
+ return Promise.reject();
+ }
+
+ const torrentContents: Array = files.map((file, index) => {
+ const stat = fileStats[index];
+
+ let priority = TorrentContentPriority.NORMAL;
+ if (!stat.wanted) {
+ priority = TorrentContentPriority.DO_NOT_DOWNLOAD;
+ } else if (stat.priority === TransmissionPriority.TR_PRI_HIGH) {
+ priority = TorrentContentPriority.HIGH;
+ }
+
+ return {
+ index,
+ path: file.name,
+ filename: file.name.split('/').pop() as string,
+ percentComplete: Math.trunc(file.bytesCompleted / file.length),
+ priority,
+ sizeBytes: file.length,
+ };
+ });
+
+ return torrentContents;
+ });
+ }
+
+ async getTorrentPeers(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager
+ .getTorrents(hash, ['peers'])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((torrents) => {
+ const [torrent] = torrents;
+ if (torrent == null) {
+ return Promise.reject();
+ }
+
+ const torrentPeers: Array = torrent.peers
+ .filter((peer) => peer.isDownloadingFrom || peer.isUploadingTo)
+ .map((peer) => ({
+ address: peer.address,
+ country: geoip.lookup(peer.address)?.country || '',
+ clientVersion: peer.clientName,
+ completedPercent: Math.trunc(peer.progress * 100),
+ downloadRate: peer.rateToClient,
+ uploadRate: peer.rateToPeer,
+ isEncrypted: peer.isEncrypted,
+ isIncoming: peer.isIncoming,
+ }));
+
+ return torrentPeers;
+ });
+ }
+
+ async getTorrentTrackers(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager
+ .getTorrents(hash, ['trackerStats'])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((torrents) => {
+ const [torrent] = torrents;
+ if (torrent == null) {
+ return Promise.reject();
+ }
+
+ const torrentTrackers: Array = torrent.trackerStats.map((tracker) => ({
+ url: tracker.announce,
+ type: tracker.announce.startsWith('udp') ? TorrentTrackerType.UDP : TorrentTrackerType.HTTP,
+ }));
+
+ return torrentTrackers;
+ });
+ }
+
+ async moveTorrents({hashes, destination, moveFiles}: MoveTorrentsOptions): Promise {
+ return this.clientRequestManager
+ .setTorrentsLocation(hashes, destination, moveFiles)
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async removeTorrents({hashes, deleteData}: DeleteTorrentsOptions): Promise {
+ return this.clientRequestManager
+ .removeTorrents(hashes, deleteData)
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise {
+ let transmissionPriority = TransmissionPriority.TR_PRI_NORMAL;
+
+ switch (priority) {
+ case TorrentPriority.DO_NOT_DOWNLOAD:
+ return undefined;
+ case TorrentPriority.LOW:
+ transmissionPriority = TransmissionPriority.TR_PRI_LOW;
+ break;
+ case TorrentPriority.HIGH:
+ transmissionPriority = TransmissionPriority.TR_PRI_HIGH;
+ break;
+ default:
+ break;
+ }
+
+ return this.clientRequestManager
+ .setTorrentsProperties({ids: hashes, bandwidthPriority: transmissionPriority})
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async setTorrentsTags({hashes, tags}: SetTorrentsTagsOptions): Promise {
+ return this.clientRequestManager
+ .setTorrentsProperties({ids: hashes, labels: tags})
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async setTorrentsTrackers({hashes, trackers}: SetTorrentsTrackersOptions): Promise {
+ const torrentsProcessed: Array = [];
+
+ // Remove existing trackers
+ await this.clientRequestManager
+ .getTorrents(hashes, ['trackers'])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((torrents) =>
+ torrents.forEach((torrent, index) => {
+ const hash = hashes[index];
+ this.clientRequestManager
+ .setTorrentsProperties({
+ ids: hash,
+ trackerRemove: torrent.trackers.map((tracker) => tracker.id),
+ })
+ .then(
+ () => {
+ torrentsProcessed.push(hash);
+ },
+ () => undefined,
+ );
+ }),
+ );
+
+ return this.clientRequestManager
+ .setTorrentsProperties({ids: torrentsProcessed, trackerAdd: trackers})
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async setTorrentContentsPriority(
+ hash: string,
+ {indices, priority}: SetTorrentContentsPropertiesOptions,
+ ): Promise {
+ let wantedArgument: keyof TransmissionTorrentsSetArguments = 'files-wanted';
+ let priorityArgument: keyof TransmissionTorrentsSetArguments = 'priority-normal';
+
+ switch (priority) {
+ case TorrentContentPriority.DO_NOT_DOWNLOAD:
+ wantedArgument = 'files-unwanted';
+ break;
+ case TorrentContentPriority.HIGH:
+ priorityArgument = 'priority-high';
+ break;
+ default:
+ break;
+ }
+
+ return this.clientRequestManager
+ .setTorrentsProperties({
+ ids: hash,
+ [wantedArgument]: indices,
+ [priorityArgument]: indices,
+ })
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async startTorrents({hashes}: StartTorrentsOptions): Promise {
+ return this.clientRequestManager
+ .startTorrents(hashes)
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async stopTorrents({hashes}: StopTorrentsOptions): Promise {
+ return this.clientRequestManager
+ .stopTorrents(hashes)
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async fetchTorrentList(): Promise {
+ return this.clientRequestManager
+ .getTorrents(null, [
+ 'hashString',
+ 'downloadDir',
+ 'name',
+ 'haveValid',
+ 'addedDate',
+ 'dateCreated',
+ 'rateDownload',
+ 'rateUpload',
+ 'downloadedEver',
+ 'uploadedEver',
+ 'eta',
+ 'isPrivate',
+ 'error',
+ 'errorString',
+ 'peersGettingFromUs',
+ 'peersSendingToUs',
+ 'status',
+ 'totalSize',
+ 'trackers',
+ 'labels',
+ ])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then(async (torrents) => {
+ this.emit('PROCESS_TORRENT_LIST_START');
+
+ const torrentList: TorrentList = Object.assign(
+ {},
+ ...(await Promise.all(
+ torrents.map(async (torrent) => {
+ const percentComplete = Math.trunc((torrent.haveValid / torrent.totalSize) * 100);
+ const ratio = torrent.downloadedEver === 0 ? -1 : torrent.uploadedEver / torrent.downloadedEver;
+ const trackerURIs = getDomainsFromURLs(torrent.trackers.map((tracker) => tracker.announce));
+ const status = torrentPropertiesUtil.getTorrentStatus(torrent);
+
+ const torrentProperties: TorrentProperties = {
+ hash: torrent.hashString,
+ name: torrent.name,
+ bytesDone: torrent.haveValid,
+ dateAdded: torrent.addedDate,
+ dateCreated: torrent.dateCreated,
+ directory: torrent.downloadDir,
+ downRate: torrent.rateDownload,
+ downTotal: torrent.downloadedEver,
+ upRate: torrent.rateUpload,
+ upTotal: torrent.uploadedEver,
+ eta: torrent.eta,
+ isPrivate: torrent.isPrivate,
+ message: torrent.errorString,
+ peersConnected: torrent.peersGettingFromUs,
+ peersTotal: torrent.peersGettingFromUs,
+ percentComplete,
+ priority: TorrentPriority.NORMAL,
+ ratio,
+ seedsConnected: torrent.peersSendingToUs,
+ seedsTotal: torrent.peersSendingToUs,
+ sizeBytes: torrent.totalSize,
+ status,
+ tags: torrent.labels || [],
+ trackerURIs,
+ };
+
+ this.emit('PROCESS_TORRENT', torrentProperties);
+
+ return {
+ [torrentProperties.hash]: torrentProperties,
+ };
+ }),
+ )),
+ );
+
+ const torrentListSummary = {
+ id: Date.now(),
+ torrents: torrentList,
+ };
+
+ this.emit('PROCESS_TORRENT_LIST_END', torrentListSummary);
+ return torrentListSummary;
+ });
+ }
+
+ async fetchTransferSummary(): Promise {
+ const statsRequest = this.clientRequestManager
+ .getSessionStats()
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .catch(() => undefined);
+
+ const speedLimitRequest = this.clientRequestManager
+ .getSessionProperties([
+ 'speed-limit-down',
+ 'speed-limit-down-enabled',
+ 'speed-limit-up',
+ 'speed-limit-up-enabled',
+ ])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .catch(() => undefined);
+
+ const stats = await statsRequest;
+ const properties = await speedLimitRequest;
+
+ if (stats == null || properties == null) {
+ return Promise.reject();
+ }
+
+ const {
+ 'speed-limit-down': speedLimitDown,
+ 'speed-limit-down-enabled': speedLimitDownEnabled,
+ 'speed-limit-up': speedLimitUp,
+ 'speed-limit-up-enabled': speedLimitUpEnabled,
+ } = properties;
+
+ const transferSummary: TransferSummary = {
+ downRate: stats.downloadSpeed,
+ downThrottle: speedLimitDownEnabled ? speedLimitDown * 1024 : 0,
+ downTotal: stats['current-stats'].downloadedBytes,
+ upRate: stats.uploadSpeed,
+ upThrottle: speedLimitUpEnabled ? speedLimitUp * 1024 : 0,
+ upTotal: stats['current-stats'].uploadedBytes,
+ };
+
+ return transferSummary;
+ }
+
+ async getClientSettings(): Promise {
+ return this.clientRequestManager
+ .getSessionProperties([
+ 'dht-enabled',
+ 'peer-port',
+ 'download-dir',
+ 'peer-port-random-on-start',
+ 'pex-enabled',
+ 'speed-limit-down',
+ 'speed-limit-down-enabled',
+ 'speed-limit-up',
+ 'speed-limit-up-enabled',
+ 'peer-limit-global',
+ 'peer-limit-per-torrent',
+ 'seed-queue-enabled',
+ 'seed-queue-size',
+ ])
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((properties) => {
+ const clientSettings: ClientSettings = {
+ dht: properties['dht-enabled'],
+ dhtPort: properties['peer-port'],
+ directoryDefault: properties['download-dir'],
+ networkHttpMaxOpen: 0,
+ networkLocalAddress: [],
+ networkMaxOpenFiles: 0,
+ networkPortOpen: true,
+ networkPortRandom: properties['peer-port-random-on-start'],
+ networkPortRange: `${properties['peer-port']}`,
+ piecesHashOnCompletion: false,
+ piecesMemoryMax: 0,
+ protocolPex: properties['pex-enabled'],
+ throttleGlobalDownMax: properties['speed-limit-down-enabled'] ? properties['speed-limit-down'] : 0,
+ throttleGlobalUpMax: properties['speed-limit-up-enabled'] ? properties['speed-limit-up'] : 0,
+ throttleMaxPeersNormal: 0,
+ throttleMaxPeersSeed: 0,
+ throttleMaxDownloads: 0,
+ throttleMaxDownloadsGlobal: 0,
+ throttleMaxUploads: 0,
+ throttleMaxUploadsGlobal: properties['seed-queue-enabled'] ? properties['seed-queue-size'] : 0,
+ throttleMinPeersNormal: 0,
+ throttleMinPeersSeed: 0,
+ trackersNumWant: 0,
+ };
+
+ return clientSettings;
+ });
+ }
+
+ async setClientSettings(settings: SetClientSettingsOptions): Promise {
+ return this.clientRequestManager
+ .setSessionProperties({
+ 'dht-enabled': settings.dht,
+ 'download-dir': settings.directoryDefault,
+ 'peer-port': settings.networkPortRange ? Number(settings.networkPortRange?.split('-')[0]) : undefined,
+ 'peer-port-random-on-start': settings.networkPortRandom,
+ 'pex-enabled': settings.protocolPex,
+ 'speed-limit-down-enabled': settings.throttleGlobalDownMax !== 0,
+ 'speed-limit-down':
+ settings.throttleGlobalDownMax != null ? Math.trunc(settings.throttleGlobalDownMax / 1024) : undefined,
+ 'speed-limit-up-enabled': settings.throttleGlobalUpMax !== 0,
+ 'speed-limit-up':
+ settings.throttleGlobalUpMax != null ? Math.trunc(settings.throttleGlobalUpMax / 1024) : undefined,
+ 'seed-queue-enabled': settings.throttleMaxUploadsGlobal !== 0,
+ 'seed-queue-size': settings.throttleMaxUploadsGlobal,
+ })
+ .then(this.processClientRequestSuccess, this.processClientRequestError);
+ }
+
+ async testGateway(): Promise {
+ return this.clientRequestManager
+ .updateSessionID()
+ .then(() => this.processClientRequestSuccess(undefined), this.processClientRequestError);
+ }
+}
+
+export default TransmissionClientGatewayService;
diff --git a/server/services/Transmission/clientRequestManager.ts b/server/services/Transmission/clientRequestManager.ts
new file mode 100644
index 00000000..548857ec
--- /dev/null
+++ b/server/services/Transmission/clientRequestManager.ts
@@ -0,0 +1,294 @@
+import axios, {AxiosError} from 'axios';
+
+import type {TransmissionConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+
+import type {
+ TransmissionSessionGetArguments,
+ TransmissionSessionProperties,
+ TransmissionSessionSetArguments,
+ TransmissionSessionStats,
+} from './types/TransmissionSessionMethods';
+import {
+ TransmissionTorrentIDs,
+ TransmissionTorrentProperties,
+ TransmissionTorrentAddArguments,
+ TransmissionTorrentsGetArguments,
+ TransmissionTorrentsRemoveArguments,
+ TransmissionTorrentsSetArguments,
+ TransmissionTorrentsSetLocationArguments,
+} from './types/TransmissionTorrentsMethods';
+
+class ClientRequestManager {
+ private rpcURL: string;
+ private authHeader: string;
+ private sessionID?: Promise;
+
+ async fetchSessionID(url = this.rpcURL, authHeader = this.authHeader): Promise {
+ return axios
+ .get(url, {
+ headers: {
+ Authorization: authHeader,
+ },
+ })
+ .then(
+ () => {
+ return undefined;
+ },
+ (err: AxiosError) => {
+ if (err.response?.status === 409) {
+ return err.response?.headers['x-transmission-session-id'];
+ }
+ throw err;
+ },
+ );
+ }
+
+ async updateSessionID(url = this.rpcURL, authHeader = this.authHeader): Promise {
+ let authFailed = false;
+
+ this.sessionID = new Promise((resolve) => {
+ this.fetchSessionID(url, authHeader).then(
+ (sessionID) => {
+ resolve(sessionID);
+ },
+ () => {
+ authFailed = true;
+ resolve(undefined);
+ },
+ );
+ });
+
+ await this.sessionID;
+
+ return authFailed ? Promise.reject() : Promise.resolve();
+ }
+
+ async getRequestHeaders(): Promise> {
+ const sessionID = await this.sessionID;
+
+ return {
+ Authorization: this.authHeader,
+ ...(sessionID == null ? {} : {'X-Transmission-Session-Id': sessionID}),
+ };
+ }
+
+ async getSessionStats(): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'session-stats'},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ return res.data.arguments;
+ });
+ }
+
+ async getSessionProperties(
+ fields: T,
+ ): Promise> {
+ const sessionGetArguments: TransmissionSessionGetArguments = {fields};
+
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'session-get', arguments: sessionGetArguments},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ return res.data.arguments;
+ });
+ }
+
+ async setSessionProperties(properties: TransmissionSessionSetArguments): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'session-set', arguments: properties},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async getTorrents(
+ ids: TransmissionTorrentIDs | null,
+ fields: T,
+ ): Promise>> {
+ const torrentsGetArguments: TransmissionTorrentsGetArguments = {
+ ids: ids || undefined,
+ fields,
+ format: 'objects',
+ };
+
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-get', arguments: torrentsGetArguments},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success' || res.data.arguments.torrents == null) {
+ throw new Error();
+ }
+ return res.data.arguments.torrents;
+ });
+ }
+
+ async addTorrent(
+ args: TransmissionTorrentAddArguments,
+ ): Promise> {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-add', arguments: args},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ return res.data.arguments;
+ });
+ }
+
+ async setTorrentsProperties(args: TransmissionTorrentsSetArguments): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-set', arguments: args},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async startTorrents(ids: TransmissionTorrentIDs): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-start-now', arguments: {ids}},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async stopTorrents(ids: TransmissionTorrentIDs): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-stop', arguments: {ids}},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async verifyTorrents(ids: TransmissionTorrentIDs): Promise {
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-verify', arguments: {ids}},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async removeTorrents(ids: TransmissionTorrentIDs, deleteData?: boolean): Promise {
+ const removeTorrentsArguments: TransmissionTorrentsRemoveArguments = {
+ ids,
+ 'delete-local-data': deleteData,
+ };
+
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-remove', arguments: removeTorrentsArguments},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ async setTorrentsLocation(ids: TransmissionTorrentIDs, location: string, move?: boolean): Promise {
+ const torrentsSetLocationArguments: TransmissionTorrentsSetLocationArguments = {
+ ids,
+ location,
+ move,
+ };
+
+ return axios
+ .post(
+ this.rpcURL,
+ {method: 'torrent-set-location', arguments: torrentsSetLocationArguments},
+ {
+ headers: await this.getRequestHeaders(),
+ },
+ )
+ .then((res) => {
+ if (res.data.result !== 'success') {
+ throw new Error();
+ }
+ });
+ }
+
+ constructor(connectionSettings: TransmissionConnectionSettings) {
+ const {url, username, password} = connectionSettings;
+
+ this.rpcURL = url;
+ this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
+
+ this.updateSessionID().catch(() => undefined);
+ setInterval(() => {
+ this.updateSessionID().catch(() => undefined);
+ }, 1000 * 60 * 60 * 8);
+ }
+}
+
+export default ClientRequestManager;
diff --git a/server/services/Transmission/types/TransmissionSessionMethods.ts b/server/services/Transmission/types/TransmissionSessionMethods.ts
new file mode 100644
index 00000000..90fcd295
--- /dev/null
+++ b/server/services/Transmission/types/TransmissionSessionMethods.ts
@@ -0,0 +1,152 @@
+import type {TransmissionTorrentIDs} from './TransmissionTorrentsMethods';
+
+interface TransmissionSessionUnits {
+ 'speed-units': Array;
+ 'speed-bytes': Array;
+ 'size-units': Array;
+ 'size-bytes': Array;
+ 'memory-units': Array;
+ 'memory-bytes': Array;
+}
+
+export interface TransmissionSessionProperties {
+ // max global download speed (KBps)
+ 'alt-speed-down': number;
+ // true means use the alt speeds
+ 'alt-speed-enabled': boolean;
+ // when to turn on alt speeds (units: minutes after midnight)
+ 'alt-speed-time-begin': number;
+ // true means the scheduled on/off times are used
+ 'alt-speed-time-enabled': boolean;
+ // when to turn off alt speeds (units: same)
+ 'alt-speed-time-end': number;
+ // what day(s) to turn on alt speeds (look at tr_sched_day)
+ 'alt-speed-time-day': number;
+ // max global upload speed (KBps)
+ 'alt-speed-up': number;
+ // location of the blocklist to use for "blocklist-update"
+ 'blocklist-url': string;
+ // true means enabled
+ 'blocklist-enabled': boolean;
+ // number of rules in the blocklist
+ 'blocklist-size': number;
+ // maximum size of the disk cache (MB)
+ 'cache-size-mb': number;
+ // location of transmission's configuration directory
+ 'config-dir': string;
+ // default path to download torrents
+ 'download-dir': string;
+ // max number of torrents to download at once (see download-queue-enabled)
+ 'download-queue-size': number;
+ // if true, limit how many torrents can be downloaded at once
+ 'download-queue-enabled': boolean;
+ // true means allow dht in public torrents
+ 'dht-enabled': boolean;
+ // "required", "preferred", "tolerated"
+ encryption: string;
+ // torrents we're seeding will be stopped if they're idle for this long
+ 'idle-seeding-limit': number;
+ // true if the seeding inactivity limit is honored by default
+ 'idle-seeding-limit-enabled': boolean;
+ // path for incomplete torrents, when enabled
+ 'incomplete-dir': string;
+ // true means keep torrents in incomplete-dir until done
+ 'incomplete-dir-enabled': boolean;
+ // true means allow Local Peer Discovery in public torrents
+ 'lpd-enabled': boolean;
+ // maximum global number of peers
+ 'peer-limit-global': number;
+ // maximum global number of peers
+ 'peer-limit-per-torrent': number;
+ // true means allow pex in public torrents
+ 'pex-enabled': boolean;
+ // port number
+ 'peer-port': number;
+ // true means pick a random peer port on launch
+ 'peer-port-random-on-start': boolean;
+ // true means enabled
+ 'port-forwarding-enabled': boolean;
+ // whether or not to consider idle torrents as stalled
+ 'queue-stalled-enabled': boolean;
+ // torrents that are idle for N minuets aren't counted toward seed-queue-size or download-queue-size
+ 'queue-stalled-minutes': number;
+ // true means append ".part" to incomplete files
+ 'rename-partial-files': boolean;
+ // the current RPC API version
+ 'rpc-version': number;
+ // the minimum RPC API version supported
+ 'rpc-version-minimum': number;
+ // filename of the script to run
+ 'script-torrent-done-filename': string;
+ // whether or not to call the "done" script
+ 'script-torrent-done-enabled': boolean;
+ // the default seed ratio for torrents to use
+ seedRatioLimit: number;
+ // true if seedRatioLimit is honored by default
+ seedRatioLimited: boolean;
+ // max number of torrents to uploaded at once (see seed-queue-enabled)
+ 'seed-queue-size': number;
+ // if true, limit how many torrents can be uploaded at once
+ 'seed-queue-enabled': boolean;
+ // max global download speed (KBps)
+ 'speed-limit-down': number;
+ // true means enabled
+ 'speed-limit-down-enabled': boolean;
+ // max global upload speed (KBps)
+ 'speed-limit-up': number;
+ // true means enabled
+ 'speed-limit-up-enabled': boolean;
+ // true means added torrents will be started right away
+ 'start-added-torrents': boolean;
+ // true means the .torrent file of added torrents will be deleted
+ 'trash-original-torrent-files': boolean;
+ // {TransmissionSessionUnits}
+ units: TransmissionSessionUnits;
+ // true means allow utp
+ 'utp-enabled': boolean;
+ // long version string "$version ($revision)"
+ version: string;
+}
+
+// Method name: "session-get"
+export interface TransmissionSessionGetArguments {
+ // fields to be fetched.
+ fields: Array;
+}
+// Method name: "session-set"
+export type TransmissionSessionSetArguments = Partial<
+ Omit<
+ TransmissionSessionProperties,
+ 'blocklist-size' | 'config-dir' | 'rpc-version' | 'rpc-version-minimum' | 'version' | 'session-id'
+ >
+>;
+
+interface TransmissionSessionHistory {
+ uploadedBytes: number;
+ downloadedBytes: number;
+ filesAdded: number;
+ sessionCount: number;
+ secondsActive: number;
+}
+
+// Method name: "session-stats"
+export interface TransmissionSessionStats {
+ torrentCount: number;
+ activeTorrentCount: number;
+ pausedTorrentCount: number;
+ downloadSpeed: number;
+ uploadSpeed: number;
+ 'cumulative-stats': TransmissionSessionHistory;
+ 'current-stats': TransmissionSessionHistory;
+}
+
+// Method name: "queue-move-top" | "queue-move-up" | "queue-move-down" | "queue-move-bottom"
+export interface TransmissionQueueMoveArguments {
+ ids: TransmissionTorrentIDs;
+}
+
+// Method name: "free-space"
+// This method tests how much free space is available in a client-specified folder.
+export interface TransmissionFreeSpaceArguments {
+ path: string;
+}
diff --git a/server/services/Transmission/types/TransmissionTorrentsMethods.ts b/server/services/Transmission/types/TransmissionTorrentsMethods.ts
new file mode 100644
index 00000000..e852385b
--- /dev/null
+++ b/server/services/Transmission/types/TransmissionTorrentsMethods.ts
@@ -0,0 +1,360 @@
+export enum TransmissionPriority {
+ TR_PRI_LOW = -1,
+ TR_PRI_NORMAL = 0,
+ TR_PRI_HIGH = 1,
+}
+
+interface TransmissionTorrentContent {
+ bytesCompleted: number;
+ length: number;
+ name: string;
+}
+
+// a file's non-constant properties.
+interface TransmissionTorrentContentStats {
+ bytesCompleted: number;
+ wanted: boolean;
+ priority: TransmissionPriority;
+}
+
+export enum TransmissionTorrentError {
+ // everything's fine
+ TR_STAT_OK = 0,
+ // when we anounced to the tracker, we got a warning in the response
+ TR_STAT_TRACKER_WARNING = 1,
+ // when we anounced to the tracker, we got an error in the response
+ TR_STAT_TRACKER_ERROR = 2,
+ // local trouble, such as disk full or permissions error
+ TR_STAT_LOCAL_ERROR = 3,
+}
+
+export enum TransmissionTorrentStatus {
+ // Torrent is stopped
+ TR_STATUS_STOPPED = 0,
+ // Queued to check files
+ TR_STATUS_CHECK_WAIT = 1,
+ // Checking files
+ TR_STATUS_CHECK = 2,
+ // Queued to download
+ TR_STATUS_DOWNLOAD_WAIT = 3,
+ // Downloading
+ TR_STATUS_DOWNLOAD = 4,
+ // Queued to seed
+ TR_STATUS_SEED_WAIT = 5,
+ // Seeding
+ TR_STATUS_SEED = 6,
+}
+
+interface TransmissionTorrentPeer {
+ address: string;
+ clientName: string;
+ clientIsChoked: boolean;
+ clientIsInterested: boolean;
+ flagStr: string;
+ isDownloadingFrom: boolean;
+ isEncrypted: boolean;
+ isIncoming: boolean;
+ isUploadingTo: boolean;
+ isUTP: boolean;
+ peerIsChoked: boolean;
+ peerIsInterested: boolean;
+ port: number;
+ progress: number;
+ // B/s
+ rateToClient: number;
+ // B/s
+ rateToPeer: number;
+}
+
+interface TransmissionTorrentPeersFrom {
+ fromCache: number;
+ fromDht: number;
+ fromIncoming: number;
+ fromLpd: number;
+ fromLtep: number;
+ fromPex: number;
+ fromTracker: number;
+}
+
+interface TransmissionTorrentTracker {
+ announce: string;
+ id: number;
+ scrape: string;
+ tier: number;
+}
+
+interface TransmissionTorrentTrackerStats {
+ announce: string;
+ announceState: number;
+ downloadCount: number;
+ hasAnnounced: boolean;
+ hasScraped: boolean;
+ host: string;
+ id: number;
+ isBackup: boolean;
+ lastAnnouncePeerCount: number;
+ lastAnnounceResult: string;
+ lastAnnounceStartTime: number;
+ lastAnnounceSucceeded: boolean;
+ lastAnnounceTime: number;
+ lastAnnounceTimedOut: boolean;
+ lastScrapeResult: string;
+ lastScrapeStartTime: number;
+ lastScrapeSucceeded: boolean;
+ lastScrapeTime: number;
+ lastScrapeTimedOut: boolean;
+ leecherCount: number;
+ nextAnnounceTime: number;
+ nextScrapeTime: number;
+ scrape: string;
+ scrapeState: number;
+ seederCount: number;
+ tier: number;
+}
+
+export interface TransmissionTorrentProperties {
+ // The last time we uploaded or downloaded piece data on this torrent.
+ activityDate: number;
+ // When the torrent was first added.
+ addedDate: number;
+ bandwidthPriority: TransmissionPriority;
+ comment: string;
+ // Byte count of all the corrupt data you've ever downloaded for this torrent.
+ corruptEver: number;
+ creator: string;
+ dateCreated: number;
+ // Byte count of all the piece data we want and don't have yet, but that a connected peer does have. [0...leftUntilDone]
+ desiredAvailable: number;
+ // When the torrent finished downloading.
+ doneDate: number;
+ downloadDir: string;
+ // Byte count of all the non-corrupt data you've ever downloaded for this torrent.
+ downloadedEver: number;
+ downloadLimit: number;
+ downloadLimited: boolean;
+ // The last time during this session that a rarely-changing field changed.
+ editDate: number;
+ error: TransmissionTorrentError;
+ // A warning or error message regarding the torrent.
+ errorString: string;
+ // If downloading, estimated number of seconds left until the torrent is done.
+ // If seeding, estimated number of seconds left until seed ratio is reached.
+ eta: number;
+ // If seeding, number of seconds left until the idle time limit is reached.
+ etaIdle: number;
+ 'file-count': number;
+ files: Array;
+ fileStats: Array;
+ hashString: string;
+ // Byte count of all the partial piece data we have for this torrent. As pieces become complete,
+ // this value may decrease as portions of it are moved to `corrupt' or `haveValid'.
+ haveUnchecked: number;
+ // Byte count of all the checksum-verified data we have for this torrent.
+ haveValid: number;
+ honorsSessionLimits: boolean;
+ // IDs are good as simple lookup keys, but are not persistent between sessions.
+ id: number;
+ isFinished: boolean;
+ isPrivate: boolean;
+ isStalled: boolean;
+ labels: Array;
+ // Byte count of how much data is left to be downloaded until we've got all the pieces that we want. [0...tr_info.sizeWhenDone]
+ leftUntilDone: number;
+ magnetLink: string;
+ // time when one or more of the torrent's trackers will allow you to manually ask for more peers, or 0 if you can't.
+ manualAnnounceTime: number;
+ maxConnectedPeers: number;
+ // How much of the metadata the torrent has. For torrents added from a .torrent this will always be 1.
+ // For magnet links, this number will from from 0 to 1 as the metadata is downloaded. Range is [0..1]
+ metadataPercentComplete: number;
+ // The torrent's name.
+ name: string;
+ 'peer-limit': number;
+ peers: Array;
+ // Number of peers that we're connected to
+ peersConnected: number;
+ // How many peers we found out about from the tracker, or from pex, or from incoming connections, or from our resume file.
+ peersFrom: TransmissionTorrentPeersFrom;
+ // Number of peers that we're sending data to
+ peersGettingFromUs: number;
+ // Number of peers that are sending data to us.
+ peersSendingToUs: number;
+ // How much has been downloaded of the files the user wants. This differs from percentComplete
+ // if the user wants only some of the torrent's files. Range is [0..1]
+ percentDone: number;
+ // A bitfield holding pieceCount flags which are set to 'true' if we have the piece matching
+ // that position. This is a base64-encoded string.
+ pieces: string;
+ pieceCount: number;
+ pieceSize: number;
+ // an array of tr_info.filecount numbers. each is the tr_priority_t mode for the corresponding file.
+ priorities: Array;
+ 'primary-mime-type': string;
+ // This torrent's queue position. All torrents have a queue position, even if it's not queued.
+ queuePosition: number;
+ // B/s
+ rateDownload: number;
+ // B/s
+ rateUpload: number;
+ // When tr_stat.activity is TR_STATUS_CHECK or TR_STATUS_CHECK_WAIT, this is the percentage of
+ // how much of the files has been verified. When it gets to 1, the verify process is done. Range is [0..1]
+ recheckProgress: number;
+ // Cumulative seconds the torrent's ever spent downloading
+ secondsDownloading: number;
+ // Cumulative seconds the torrent's ever spent seeding
+ secondsSeeding: number;
+ seedIdleLimit: number;
+ seedIdleMode: number;
+ seedRatioLimit: number;
+ seedRatioMode: number;
+ // Byte count of all the piece data we'll have downloaded when we're done, whether or not we have
+ // it yet. This may be less than tr_info.totalSize if only some of the torrent's files are wanted. [0...tr_info.totalSize]
+ sizeWhenDone: number;
+ // When the torrent was last started.
+ startDate: number;
+ status: TransmissionTorrentStatus;
+ trackers: Array;
+ trackerStats: Array;
+ // total size of the torrent, in bytes
+ totalSize: number;
+ torrentFile: string;
+ // Byte count of all data you've ever uploaded for this torrent.
+ uploadedEver: number;
+ uploadLimit: number;
+ uploadLimited: boolean;
+ uploadRatio: number;
+ // an array of tr_info.fileCount 'booleans' true if the corresponding file is to be downloaded.
+ wanted: Array;
+ webseeds: Array;
+ // Number of webseeds that are sending data to us.
+ webseedsSendingToUs: number;
+}
+
+// number representing torrent id or string representing torrent hash or an array thereof.
+export type TransmissionTorrentIDs = Array | string | number;
+
+// Method name: "torrent-get"
+export interface TransmissionTorrentsGetArguments {
+ // torrent list. All torrents are used if the "ids" argument is omitted.
+ ids?: TransmissionTorrentIDs;
+ // fields to be fetched.
+ fields: Array;
+ // how to format the "torrents" response field.
+ format: 'objects' | 'table';
+}
+
+// Method name: "torrent-set"
+export interface TransmissionTorrentsSetArguments {
+ // torrent list. All torrents are used if the "ids" argument is omitted.
+ ids?: TransmissionTorrentIDs;
+ // this torrent's bandwidth tr_priority_t
+ bandwidthPriority?: TransmissionPriority;
+ // maximum download speed (KBps)
+ downloadLimit?: number;
+ // true if "downloadLimit" is honored
+ downloadLimited?: boolean;
+ // indices of file(s) to download
+ 'files-wanted'?: Array;
+ // indices of file(s) to not download
+ 'files-unwanted'?: Array;
+ // true if session upload limits are honored
+ honorsSessionLimits?: boolean;
+ // array of string labels
+ labels?: Array;
+ // new location of the torrent's content
+ location?: string;
+ // maximum number of peers
+ 'peer-limit'?: number;
+ // indices of high-priority file(s)
+ 'priority-high'?: Array;
+ // indices of low-priority file(s)
+ 'priority-low'?: Array;
+ // indices of normal-priority file(s)
+ 'priority-normal'?: Array;
+ // position of this torrent in its queue [0...n)
+ queuePosition?: number;
+ // torrent-level number of minutes of seeding inactivity
+ seedIdleLimit?: number;
+ // which seeding inactivity to use. See tr_idlelimit
+ seedIdleMode?: number;
+ // torrent-level seeding ratio
+ seedRatioLimit?: number;
+ // which ratio to use. See tr_ratiolimit
+ seedRatioMode?: number;
+ // strings of announce URLs to add
+ trackerAdd?: Array;
+ // ids of trackers to remove
+ trackerRemove?: Array;
+ // pairs of , [0, url, 1, url, ...]
+ trackerReplace?: Array;
+ // maximum upload speed (KBps)
+ uploadLimit?: number;
+ // true if "uploadLimit" is honored
+ uploadLimited?: boolean;
+}
+
+// Method name: "torrent-add"
+interface TransmissionTorrentAddCommonArguments {
+ // path to download the torrent to
+ 'download-dir'?: string;
+ // if true, don't start the torrent
+ paused?: boolean;
+ // maximum number of peers
+ 'peer-limit'?: number;
+ // torrent's bandwidth tr_priority_t
+ bandwidthPriority?: TransmissionPriority;
+ // indices of file(s) to download
+ 'files-wanted'?: Array;
+ // indices of file(s) to not download
+ 'files-unwanted'?: Array;
+ // indices of high-priority file(s)
+ 'priority-high'?: Array;
+ // indices of low-priority file(s)
+ 'priority-low'?: Array;
+ // indices of normal-priority file(s)
+ 'priority-normal'?: Array;
+}
+
+interface TransmissionTorrentAddByURLArguments extends TransmissionTorrentAddCommonArguments {
+ // filename or URL of the .torrent file
+ filename: string;
+ // pointer to a string of one or more cookies.
+ // The format of the "cookies" should be NAME=CONTENTS, where NAME is the cookie name and CONTENTS
+ // is what the cookie should contain. Set multiple cookies like this: "name1=content1; name2=content2;".
+ cookies?: string;
+}
+
+interface TransmissionTorrentAddByFileArguments extends TransmissionTorrentAddCommonArguments {
+ // base64-encoded .torrent content
+ metainfo: string;
+}
+
+export type TransmissionTorrentAddArguments =
+ | TransmissionTorrentAddByURLArguments
+ | TransmissionTorrentAddByFileArguments;
+
+// Method name: "torrent-remove"
+export interface TransmissionTorrentsRemoveArguments {
+ ids: TransmissionTorrentIDs;
+ // delete local data. (default: false)
+ 'delete-local-data'?: boolean;
+}
+
+// Method name: "torrent-set-location"
+export interface TransmissionTorrentsSetLocationArguments {
+ ids: TransmissionTorrentIDs;
+ // the new torrent location
+ location: string;
+ // if true, move from previous location. otherwise, search "location" for files. (default: false)
+ move?: boolean;
+}
+
+// Method name: "torrent-rename-path"
+export interface TransmissionTorrentRenamePathArguments {
+ // must only be 1 torrent
+ ids: TransmissionTorrentIDs;
+ // the path to the file or folder that will be renamed
+ path: string;
+ // the file or folder's new name
+ name: string;
+}
diff --git a/server/services/Transmission/util/torrentPropertiesUtil.ts b/server/services/Transmission/util/torrentPropertiesUtil.ts
new file mode 100644
index 00000000..05814161
--- /dev/null
+++ b/server/services/Transmission/util/torrentPropertiesUtil.ts
@@ -0,0 +1,56 @@
+import {TransmissionTorrentError, TransmissionTorrentStatus} from '../types/TransmissionTorrentsMethods';
+
+import type {TorrentProperties} from '../../../../shared/types/Torrent';
+import type {TransmissionTorrentProperties} from '../types/TransmissionTorrentsMethods';
+
+const getTorrentStatus = (
+ properties: Pick<
+ TransmissionTorrentProperties,
+ 'error' | 'status' | 'rateDownload' | 'rateUpload' | 'haveValid' | 'totalSize'
+ >,
+): TorrentProperties['status'] => {
+ const {error, status, rateDownload, rateUpload, haveValid, totalSize} = properties;
+ const statuses: TorrentProperties['status'] = [];
+
+ switch (status) {
+ case TransmissionTorrentStatus.TR_STATUS_CHECK:
+ case TransmissionTorrentStatus.TR_STATUS_CHECK_WAIT:
+ statuses.push('checking');
+ break;
+ case TransmissionTorrentStatus.TR_STATUS_DOWNLOAD:
+ case TransmissionTorrentStatus.TR_STATUS_DOWNLOAD_WAIT:
+ statuses.push('downloading');
+ if (rateDownload > 0) {
+ statuses.push('active');
+ } else {
+ statuses.push('inactive');
+ }
+ break;
+ case TransmissionTorrentStatus.TR_STATUS_SEED:
+ case TransmissionTorrentStatus.TR_STATUS_SEED_WAIT:
+ statuses.push('seeding');
+ if (rateUpload > 0) {
+ statuses.push('active');
+ } else {
+ statuses.push('inactive');
+ }
+ break;
+ case TransmissionTorrentStatus.TR_STATUS_STOPPED:
+ statuses.push('stopped', 'inactive');
+ break;
+ default:
+ break;
+ }
+
+ if (error !== TransmissionTorrentError.TR_STAT_OK) {
+ statuses.push('error');
+ }
+
+ if (haveValid === totalSize) {
+ statuses.push('complete');
+ }
+
+ return statuses;
+};
+
+export default {getTorrentStatus};
diff --git a/server/services/index.ts b/server/services/index.ts
index 95ccd413..0267781a 100644
--- a/server/services/index.ts
+++ b/server/services/index.ts
@@ -11,11 +11,13 @@ import TorrentService from './torrentService';
import QBittorrentClientGatewayService from './qBittorrent/clientGatewayService';
import RTorrentClientGatewayService from './rTorrent/clientGatewayService';
+import TransmissionClientGatewayService from './Transmission/clientGatewayService';
type ClientGatewayServiceImpl = typeof ClientGatewayService & {
new (...args: ConstructorParameters):
| QBittorrentClientGatewayService
- | RTorrentClientGatewayService;
+ | RTorrentClientGatewayService
+ | TransmissionClientGatewayService;
};
type Service =
@@ -66,6 +68,8 @@ const getClientGatewayService = (user: UserInDatabase): ClientGatewayService | u
return getService('clientGatewayServices', QBittorrentClientGatewayService, user);
case 'rTorrent':
return getService('clientGatewayServices', RTorrentClientGatewayService, user);
+ case 'Transmission':
+ return getService('clientGatewayServices', TransmissionClientGatewayService, user);
default:
return undefined;
}
diff --git a/server/services/interfaces/clientGatewayService.ts b/server/services/interfaces/clientGatewayService.ts
index f0f44a6f..e60e5da8 100644
--- a/server/services/interfaces/clientGatewayService.ts
+++ b/server/services/interfaces/clientGatewayService.ts
@@ -125,8 +125,7 @@ abstract class ClientGatewayService extends BaseService} indices - Indices of contents to be altered.
- * @param {number} priority - Target priority.
+ * @param {SetTorrentContentsPropertiesOptions} options - An object of options...
* @return {Promise} - Rejects with error.
*/
abstract setTorrentContentsPriority(hash: string, options: SetTorrentContentsPropertiesOptions): Promise;
diff --git a/shared/schema/ClientConnectionSettings.ts b/shared/schema/ClientConnectionSettings.ts
index 1e043353..a08d4a67 100644
--- a/shared/schema/ClientConnectionSettings.ts
+++ b/shared/schema/ClientConnectionSettings.ts
@@ -50,7 +50,7 @@ export type RTorrentConnectionSettings = zodInfer;
diff --git a/shared/schema/constants/ClientConnectionSettings.ts b/shared/schema/constants/ClientConnectionSettings.ts
index 2006de87..3d59b200 100644
--- a/shared/schema/constants/ClientConnectionSettings.ts
+++ b/shared/schema/constants/ClientConnectionSettings.ts
@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
-export const SUPPORTED_CLIENTS = ['qBittorrent', 'rTorrent'] as const;
+export const SUPPORTED_CLIENTS = ['qBittorrent', 'rTorrent', 'Transmission'] as const;