diff --git a/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
index 01dad86b..35d6d18e 100644
--- a/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
+++ b/client/src/javascript/components/general/connection-settings/ClientConnectionSettingsForm.tsx
@@ -1,41 +1,51 @@
import React from 'react';
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
+import {SUPPORTED_CLIENTS} from '@shared/schema/ClientConnectionSettings';
+
import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+import QBittorrentConnectionSettingsForm from './QBittorrentConnectionSettingsForm';
import RTorrentConnectionSettingsForm from './RTorrentConnectionSettingsForm';
import {FormRow, Select, SelectItem} from '../../../ui';
+const DEFAULT_SELECTION: ClientConnectionSettings['client'] = 'rTorrent' as const;
+
const getClientSelectItems = (): React.ReactNodeArray => {
- return [
-
-
- ,
- ];
+ return SUPPORTED_CLIENTS.map((client) => {
+ return (
+
+
+
+ );
+ });
};
+type ConnectionSettingsForm = QBittorrentConnectionSettingsForm | RTorrentConnectionSettingsForm;
+
interface ClientConnectionSettingsFormStates {
client: ClientConnectionSettings['client'];
}
class ClientConnectionSettingsForm extends React.Component {
- settingsRef: React.RefObject = React.createRef();
+ settingsRef: React.RefObject = React.createRef();
constructor(props: WrappedComponentProps) {
super(props);
- // Only rTorrent is supported at this moment.
this.state = {
- client: 'rTorrent',
+ client: DEFAULT_SELECTION,
};
}
getConnectionSettings(): ClientConnectionSettings | null {
- if (this.settingsRef.current == null) {
+ const settingsForm = this.settingsRef as React.RefObject;
+
+ if (settingsForm.current == null) {
return null;
}
- return this.settingsRef.current.getConnectionSettings();
+ return settingsForm.current.getConnectionSettings();
}
render() {
@@ -44,6 +54,9 @@ class ClientConnectionSettingsForm extends React.Component;
+ break;
case 'rTorrent':
settingsForm = ;
break;
@@ -59,7 +72,8 @@ class ClientConnectionSettingsForm extends React.Component {
this.setState({client: selectedClient as ClientConnectionSettings['client']});
- }}>
+ }}
+ defaultID={DEFAULT_SELECTION}>
{getClientSelectItems()}
diff --git a/client/src/javascript/components/general/connection-settings/QBittorrentConnectionSettingsForm.tsx b/client/src/javascript/components/general/connection-settings/QBittorrentConnectionSettingsForm.tsx
new file mode 100644
index 00000000..8a03061e
--- /dev/null
+++ b/client/src/javascript/components/general/connection-settings/QBittorrentConnectionSettingsForm.tsx
@@ -0,0 +1,114 @@
+import {FormattedMessage, IntlShape} from 'react-intl';
+import React, {Component} from 'react';
+
+import type {QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+
+import {FormGroup, FormRow, Textbox} from '../../../ui';
+
+export interface QBittorrentConnectionSettingsProps {
+ intl: IntlShape;
+}
+
+export interface QBittorrentConnectionSettingsFormData {
+ url: string;
+ username: string;
+ password: string;
+}
+
+class QBittorrentConnectionSettingsForm extends Component<
+ QBittorrentConnectionSettingsProps,
+ QBittorrentConnectionSettingsFormData
+> {
+ constructor(props: QBittorrentConnectionSettingsProps) {
+ super(props);
+ this.getConnectionSettings = this.getConnectionSettings.bind(this);
+ this.handleFormChange = this.handleFormChange.bind(this);
+
+ this.state = {
+ url: '',
+ username: '',
+ password: '',
+ };
+ }
+
+ getConnectionSettings(): QBittorrentConnectionSettings | null {
+ if (this.state.url == null || this.state.url === '') {
+ return null;
+ }
+
+ const settings: QBittorrentConnectionSettings = {
+ client: 'qBittorrent',
+ type: 'web',
+ 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 QBittorrentConnectionSettingsFormData,
+ ) {
+ 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.qbittorrent.url.input.placeholder',
+ })}
+ />
+
+
+ this.handleFormChange(e, 'username')}
+ id="qbt-username"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.qbittorrent.username.input.placeholder',
+ })}
+ autoComplete="off"
+ />
+ this.handleFormChange(e, 'password')}
+ id="qbt-password"
+ label={}
+ placeholder={this.props.intl.formatMessage({
+ id: 'connection.settings.qbittorrent.password.input.placeholder',
+ })}
+ autoComplete="off"
+ type="password"
+ />
+
+
+
+ );
+ }
+}
+
+export default QBittorrentConnectionSettingsForm;
diff --git a/client/src/javascript/i18n/strings.compiled.json b/client/src/javascript/i18n/strings.compiled.json
index 61071260..387b5b85 100644
--- a/client/src/javascript/i18n/strings.compiled.json
+++ b/client/src/javascript/i18n/strings.compiled.json
@@ -467,6 +467,48 @@
"value": "Connection settings can not be empty."
}
],
+ "connection.settings.qbittorrent": [
+ {
+ "type": 0,
+ "value": "qBittorrent"
+ }
+ ],
+ "connection.settings.qbittorrent.password": [
+ {
+ "type": 0,
+ "value": "Password"
+ }
+ ],
+ "connection.settings.qbittorrent.password.input.placeholder": [
+ {
+ "type": 0,
+ "value": "Password"
+ }
+ ],
+ "connection.settings.qbittorrent.url": [
+ {
+ "type": 0,
+ "value": "URL"
+ }
+ ],
+ "connection.settings.qbittorrent.url.input.placeholder": [
+ {
+ "type": 0,
+ "value": "URL to qBittorrent Web API"
+ }
+ ],
+ "connection.settings.qbittorrent.username": [
+ {
+ "type": 0,
+ "value": "Username"
+ }
+ ],
+ "connection.settings.qbittorrent.username.input.placeholder": [
+ {
+ "type": 0,
+ "value": "Username"
+ }
+ ],
"connection.settings.rtorrent": [
{
"type": 0,
diff --git a/client/src/javascript/i18n/strings.json b/client/src/javascript/i18n/strings.json
index 0a29f623..79fd84c8 100644
--- a/client/src/javascript/i18n/strings.json
+++ b/client/src/javascript/i18n/strings.json
@@ -47,6 +47,13 @@
"connection.settings.rtorrent.port.input.placeholder": "Port",
"connection.settings.rtorrent.socket": "Path",
"connection.settings.rtorrent.socket.input.placeholder": "Path to socket",
+ "connection.settings.qbittorrent": "qBittorrent",
+ "connection.settings.qbittorrent.url": "URL",
+ "connection.settings.qbittorrent.url.input.placeholder": "URL to qBittorrent Web API",
+ "connection.settings.qbittorrent.username": "Username",
+ "connection.settings.qbittorrent.username.input.placeholder": "Username",
+ "connection.settings.qbittorrent.password": "Password",
+ "connection.settings.qbittorrent.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 21fdcb3a..39406baa 100644
--- a/config.cli.js
+++ b/config.cli.js
@@ -52,6 +52,18 @@ const {argv} = require('yargs')
describe: "Depends on noauth: Path to rTorrent's SCGI unix socket",
type: 'string',
})
+ .option('qburl', {
+ describe: 'Depends on noauth: URL to qBittorrent Web API',
+ type: 'string',
+ })
+ .option('qbuser', {
+ describe: 'Depends on noauth: Username of qBittorrent Web API',
+ type: 'string',
+ })
+ .option('qbpass', {
+ describe: 'Depends on noauth: Password of qBittorrent Web API',
+ type: 'string',
+ })
.option('ssl', {
default: false,
describe: 'Enable SSL, key.pem and fullchain.pem needed in runtime directory',
@@ -154,27 +166,42 @@ if (!argv.secret) {
({secret} = argv);
}
+let connectionSettings;
+if (argv.rtsocket != null || argv.rthost != null) {
+ if (argv.rtsocket != null) {
+ connectionSettings = {
+ client: 'rTorrent',
+ type: 'socket',
+ version: 1,
+ socket: argv.rtsocket,
+ };
+ } else {
+ connectionSettings = {
+ client: 'rTorrent',
+ type: 'tcp',
+ version: 1,
+ host: argv.rthost,
+ port: argv.rtport,
+ };
+ }
+} else if (argv.qburl != null) {
+ connectionSettings = {
+ client: 'qBittorrent',
+ type: 'web',
+ version: 1,
+ url: argv.qburl,
+ username: argv.qbuser,
+ password: argv.qbpass,
+ };
+}
+
const CONFIG = {
baseURI: argv.baseuri,
dbCleanInterval: argv.dbclean,
dbPath: path.resolve(path.join(argv.rundir, 'db')),
tempPath: path.resolve(path.join(argv.rundir, 'temp')),
disableUsersAndAuth: argv.noauth,
- configUser:
- argv.rtsocket != null
- ? {
- client: 'rTorrent',
- type: 'socket',
- version: 1,
- socket: argv.rtsocket || '/data/rtorrent.sock',
- }
- : {
- client: 'rTorrent',
- type: 'tcp',
- version: 1,
- host: argv.rthost || 'localhost',
- port: argv.rtport || 5000,
- },
+ configUser: connectionSettings,
floodServerHost: argv.host,
floodServerPort: argv.port,
floodServerProxy: argv.proxy,
diff --git a/jest.config.js b/jest.config.js
index 28775dc8..53f5e10a 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,5 +2,10 @@ module.exports = {
verbose: true,
collectCoverage: true,
coverageProvider: 'v8',
- projects: ['/server/.jest/auth.config.js', '/server/.jest/test.config.js'],
+ projects: [
+ '/server/.jest/auth.config.js',
+ '/server/.jest/rtorrent.config.js',
+ // TODO: qBittorrent tests are disabled at the moment.
+ // '/server/.jest/qbittorrent.config.js',
+ ],
};
diff --git a/package-lock.json b/package-lock.json
index f5c9fab3..10af718a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -100,6 +100,7 @@
"fast-sort": "^2.2.0",
"feedsub": "^0.7.2",
"file-loader": "^6.1.1",
+ "form-data": "^3.0.0",
"frontmatter-markdown-loader": "^3.6.1",
"fs-extra": "^9.0.1",
"get-user-locale": "^1.4.0",
@@ -10383,17 +10384,17 @@
}
},
"node_modules/form-data": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
- "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
+ "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
- "combined-stream": "^1.0.6",
+ "combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
- "node": ">= 0.12"
+ "node": ">= 6"
}
},
"node_modules/formidable": {
@@ -21706,6 +21707,20 @@
"node": ">=0.8"
}
},
+ "node_modules/request/node_modules/form-data": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "dev": true,
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.6",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 0.12"
+ }
+ },
"node_modules/request/node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -24324,20 +24339,6 @@
"node": ">= 7.0.0"
}
},
- "node_modules/superagent/node_modules/form-data": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
- "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
- "dev": true,
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/superagent/node_modules/mime": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz",
@@ -36813,13 +36814,13 @@
}
},
"form-data": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
- "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
+ "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
- "combined-stream": "^1.0.6",
+ "combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
@@ -45872,6 +45873,17 @@
"uuid": "^3.3.2"
},
"dependencies": {
+ "form-data": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+ "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+ "dev": true,
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.6",
+ "mime-types": "^2.1.12"
+ }
+ },
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -48081,17 +48093,6 @@
"semver": "^7.3.2"
},
"dependencies": {
- "form-data": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.0.tgz",
- "integrity": "sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==",
- "dev": true,
- "requires": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "mime-types": "^2.1.12"
- }
- },
"mime": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.4.6.tgz",
diff --git a/package.json b/package.json
index ed64e215..a7851dcf 100644
--- a/package.json
+++ b/package.json
@@ -138,6 +138,7 @@
"fast-sort": "^2.2.0",
"feedsub": "^0.7.2",
"file-loader": "^6.1.1",
+ "form-data": "^3.0.0",
"frontmatter-markdown-loader": "^3.6.1",
"fs-extra": "^9.0.1",
"get-user-locale": "^1.4.0",
diff --git a/server/.jest/auth.config.js b/server/.jest/auth.config.js
index e41e18b2..f04b8e04 100644
--- a/server/.jest/auth.config.js
+++ b/server/.jest/auth.config.js
@@ -1,4 +1,5 @@
module.exports = {
+ displayName: 'auth',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
testEnvironment: 'node',
diff --git a/server/.jest/qbittorrent.config.js b/server/.jest/qbittorrent.config.js
new file mode 100644
index 00000000..27acec67
--- /dev/null
+++ b/server/.jest/qbittorrent.config.js
@@ -0,0 +1,13 @@
+module.exports = {
+ displayName: 'qbittorrent',
+ preset: 'ts-jest/presets/js-with-babel',
+ rootDir: './../',
+ testEnvironment: 'node',
+ testPathIgnorePatterns: ['auth.test.ts'],
+ setupFilesAfterEnv: ['/.jest/qbittorrent.setup.js'],
+ globals: {
+ 'ts-jest': {
+ isolatedModules: true,
+ },
+ },
+};
diff --git a/server/.jest/qbittorrent.setup.js b/server/.jest/qbittorrent.setup.js
new file mode 100644
index 00000000..71da5c4f
--- /dev/null
+++ b/server/.jest/qbittorrent.setup.js
@@ -0,0 +1,32 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import {spawn} from 'child_process';
+
+const temporaryRuntimeDirectory = path.resolve(os.tmpdir(), `flood.test.${crypto.randomBytes(12).toString('hex')}`);
+
+fs.mkdirSync(temporaryRuntimeDirectory, {recursive: true});
+
+const qbtPort = Math.floor(Math.random() * (65534 - 20000) + 20000);
+
+const qBittorrentDaemon = spawn(
+ 'qbittorrent-nox',
+ [`--webui-port=${qbtPort}`, `--profile=${temporaryRuntimeDirectory}`],
+ {
+ stdio: 'ignore',
+ killSignal: 'SIGKILL',
+ },
+);
+
+process.argv = ['node', 'flood'];
+process.argv.push('--rundir', temporaryRuntimeDirectory);
+process.argv.push('--noauth');
+process.argv.push('--qburl', `http://127.0.0.1:${qbtPort}`);
+process.argv.push('--qbuser', 'admin');
+process.argv.push('--qbpass', 'adminadmin');
+
+afterAll(() => {
+ qBittorrentDaemon.kill('SIGKILL');
+ fs.rmdirSync(temporaryRuntimeDirectory, {recursive: true});
+});
diff --git a/server/.jest/test.config.js b/server/.jest/rtorrent.config.js
similarity index 72%
rename from server/.jest/test.config.js
rename to server/.jest/rtorrent.config.js
index 07469dd4..502d2762 100644
--- a/server/.jest/test.config.js
+++ b/server/.jest/rtorrent.config.js
@@ -1,9 +1,10 @@
module.exports = {
+ displayName: 'rtorrent',
preset: 'ts-jest/presets/js-with-babel',
rootDir: './../',
testEnvironment: 'node',
testPathIgnorePatterns: ['auth.test.ts'],
- setupFilesAfterEnv: ['/.jest/test.setup.js'],
+ setupFilesAfterEnv: ['/.jest/rtorrent.setup.js'],
globals: {
'ts-jest': {
isolatedModules: true,
diff --git a/server/.jest/test.setup.js b/server/.jest/rtorrent.setup.js
similarity index 100%
rename from server/.jest/test.setup.js
rename to server/.jest/rtorrent.setup.js
diff --git a/server/routes/api/client.test.ts b/server/routes/api/client.test.ts
index 9c860616..9a39df72 100644
--- a/server/routes/api/client.test.ts
+++ b/server/routes/api/client.test.ts
@@ -29,8 +29,8 @@ describe('GET /api/client/connection-test', () => {
});
const settings: Partial = {
- throttleGlobalDownMax: 100,
- throttleGlobalUpMax: 100,
+ throttleGlobalDownMax: 2048,
+ throttleGlobalUpMax: 2048,
};
describe('PATCH /api/client/settings', () => {
diff --git a/server/services/index.ts b/server/services/index.ts
index 946d2c4d..95ccd413 100644
--- a/server/services/index.ts
+++ b/server/services/index.ts
@@ -9,10 +9,13 @@ import SettingService from './settingService';
import TaxonomyService from './taxonomyService';
import TorrentService from './torrentService';
+import QBittorrentClientGatewayService from './qBittorrent/clientGatewayService';
import RTorrentClientGatewayService from './rTorrent/clientGatewayService';
type ClientGatewayServiceImpl = typeof ClientGatewayService & {
- new (...args: ConstructorParameters): RTorrentClientGatewayService;
+ new (...args: ConstructorParameters):
+ | QBittorrentClientGatewayService
+ | RTorrentClientGatewayService;
};
type Service =
@@ -59,6 +62,8 @@ const getService = (servicesMap: ServiceMap, Service: S, user
const getClientGatewayService = (user: UserInDatabase): ClientGatewayService | undefined => {
switch (user.client.client) {
+ case 'qBittorrent':
+ return getService('clientGatewayServices', QBittorrentClientGatewayService, user);
case 'rTorrent':
return getService('clientGatewayServices', RTorrentClientGatewayService, user);
default:
diff --git a/server/services/qBittorrent/clientGatewayService.ts b/server/services/qBittorrent/clientGatewayService.ts
new file mode 100644
index 00000000..d01404fd
--- /dev/null
+++ b/server/services/qBittorrent/clientGatewayService.ts
@@ -0,0 +1,363 @@
+import crypto from 'crypto';
+
+import type {ClientSettings} from '@shared/types/ClientSettings';
+import type {ClientConnectionSettings, QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+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 {
+ 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 formatUtil from '../../../shared/util/formatUtil';
+import {getDomainsFromURLs} from '../../util/torrentPropertiesUtil';
+import {
+ getTorrentPeerPropertiesFromFlags,
+ getTorrentStatusFromState,
+ getTorrentTrackerTypeFromURL,
+} from './util/torrentPropertiesUtil';
+import {QBittorrentTorrentContentPriority, QBittorrentTorrentTrackerStatus} from './types/QBittorrentTorrentsMethods';
+import {TorrentContentPriority} from '../../../shared/types/TorrentContent';
+import {TorrentPriority} from '../../../shared/types/Torrent';
+
+class QBittorrentClientGatewayService extends ClientGatewayService {
+ clientRequestManager = new ClientRequestManager(this.user.client as QBittorrentConnectionSettings);
+
+ async addTorrentsByFile({files, destination, isBasePath, start}: AddTorrentByFileOptions): Promise {
+ const fileBuffers = files.map((file) => {
+ return Buffer.from(file, 'base64');
+ });
+
+ // TODO: qBittorrent does not have capability to add tags during add torrents.
+
+ return this.clientRequestManager.torrentsAddFiles(fileBuffers, {
+ savepath: destination,
+ paused: !start,
+ root_folder: !isBasePath,
+ });
+ }
+
+ async addTorrentsByURL({urls, destination, isBasePath, start}: AddTorrentByURLOptions): Promise {
+ // TODO: qBittorrent does not have capability to add tags during add torrents.
+
+ return this.clientRequestManager.torrentsAddURLs(urls, {
+ savepath: destination,
+ paused: !start,
+ root_folder: !isBasePath,
+ });
+ }
+
+ async checkTorrents({hashes}: CheckTorrentsOptions): Promise {
+ return this.clientRequestManager.torrentsRecheck(hashes);
+ }
+
+ async getTorrentContents(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager.getTorrentContents(hash).then((contents) => {
+ return contents.map((content, index) => {
+ let priority = TorrentContentPriority.NORMAL;
+
+ switch (content.priority) {
+ case QBittorrentTorrentContentPriority.DO_NOT_DOWNLOAD:
+ priority = TorrentContentPriority.DO_NOT_DOWNLOAD;
+ break;
+ case QBittorrentTorrentContentPriority.HIGH:
+ case QBittorrentTorrentContentPriority.MAXIMUM:
+ priority = TorrentContentPriority.HIGH;
+ break;
+ default:
+ break;
+ }
+
+ return {
+ index,
+ path: content.name,
+ filename: content.name.split('/').pop() || '',
+ percentComplete: Math.trunc(content.progress * 100),
+ priority,
+ sizeBytes: content.size,
+ };
+ });
+ });
+ }
+
+ async getTorrentPeers(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager.syncTorrentPeers(hash).then((peers) => {
+ return Object.keys(peers).reduce((accumulator: Array, ip_and_port) => {
+ const peer = peers[ip_and_port];
+
+ // Only displays connected peers
+ if (!peer.flags.includes('D')) {
+ return accumulator;
+ }
+
+ const properties = getTorrentPeerPropertiesFromFlags(peer.flags);
+ accumulator.push({
+ country: peer.country_code,
+ address: peer.ip,
+ completedPercent: Math.trunc(peer.progress * 100),
+ clientVersion: peer.client,
+ downloadRate: peer.dl_speed,
+ downloadTotal: peer.downloaded,
+ uploadRate: peer.up_speed,
+ uploadTotal: peer.uploaded,
+ id: crypto.createHash('sha1').update(ip_and_port).digest('base64'),
+ peerRate: 0,
+ peerTotal: 0,
+ isEncrypted: properties.isEncrypted,
+ isIncoming: properties.isIncoming,
+ });
+
+ return accumulator;
+ }, []);
+ });
+ }
+
+ async getTorrentTrackers(hash: TorrentProperties['hash']): Promise> {
+ return this.clientRequestManager.getTorrentTrackers(hash).then((trackers) => {
+ return trackers.map((tracker, index) => {
+ return {
+ index,
+ id: crypto.createHash('sha1').update(tracker.url).digest('base64'),
+ url: tracker.url,
+ type: getTorrentTrackerTypeFromURL(tracker.url),
+ group: tracker.tier,
+ minInterval: 0,
+ normalInterval: 0,
+ isEnabled: tracker.status !== QBittorrentTorrentTrackerStatus.DISABLED,
+ };
+ });
+ });
+ }
+
+ async moveTorrents({hashes, destination}: MoveTorrentsOptions): Promise {
+ return this.clientRequestManager.torrentsSetLocation(hashes, destination);
+ }
+
+ async removeTorrents({hashes, deleteData}: DeleteTorrentsOptions): Promise {
+ return this.clientRequestManager.torrentsDelete(hashes, deleteData || false);
+ }
+
+ async setTorrentsPriority({hashes, priority}: SetTorrentsPriorityOptions): Promise {
+ // TODO: qBittorrent uses queue and priority here has a different meaning
+ switch (priority) {
+ case TorrentPriority.DO_NOT_DOWNLOAD:
+ return this.stopTorrents({hashes});
+ case TorrentPriority.LOW:
+ return this.clientRequestManager.torrentsSetBottomPrio(hashes);
+ case TorrentPriority.HIGH:
+ return this.clientRequestManager.torrentsSetTopPrio(hashes);
+ default:
+ return undefined;
+ }
+ }
+
+ async setTorrentsTags({hashes, tags}: SetTorrentsTagsOptions): Promise {
+ return this.clientRequestManager.torrentsAddTags(hashes, tags);
+ }
+
+ async setTorrentsTrackers({hashes, trackers}: SetTorrentsTrackersOptions): Promise {
+ await Promise.all(
+ hashes.map((hash) => {
+ return this.clientRequestManager.torrentsAddTrackers(hash, trackers);
+ }),
+ );
+ }
+
+ async setTorrentContentsPriority(
+ hash: string,
+ {indices, priority}: SetTorrentContentsPropertiesOptions,
+ ): Promise {
+ let qbFilePriority = QBittorrentTorrentContentPriority.NORMAL;
+
+ switch (priority) {
+ case TorrentContentPriority.DO_NOT_DOWNLOAD:
+ qbFilePriority = QBittorrentTorrentContentPriority.DO_NOT_DOWNLOAD;
+ break;
+ case TorrentContentPriority.HIGH:
+ qbFilePriority = QBittorrentTorrentContentPriority.HIGH;
+ break;
+ default:
+ break;
+ }
+
+ return this.clientRequestManager.torrentsFilePrio(hash, indices, qbFilePriority);
+ }
+
+ async startTorrents({hashes}: StartTorrentsOptions): Promise {
+ return this.clientRequestManager.torrentsResume(hashes);
+ }
+
+ async stopTorrents({hashes}: StopTorrentsOptions): Promise {
+ return this.clientRequestManager.torrentsPause(hashes);
+ }
+
+ async fetchTorrentList(): Promise {
+ return this.clientRequestManager
+ .getTorrentInfos()
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((infos) => {
+ this.emit('PROCESS_TORRENT_LIST_START');
+ const torrentList: TorrentList = Object.assign(
+ {},
+ ...infos.map((info) => {
+ const torrentProperties: TorrentProperties = {
+ baseDirectory: info.save_path,
+ baseFilename: info.name,
+ basePath: info.save_path,
+ bytesDone: info.completed,
+ dateAdded: info.added_on,
+ dateCreated: 0, // need properties
+ directory: info.save_path,
+ downRate: info.dlspeed,
+ downTotal: info.downloaded,
+ eta: formatUtil.secondsToDuration(info.eta),
+ hash: info.hash,
+ isMultiFile: false,
+ isPrivate: false,
+ message: '', // in tracker method
+ name: info.name,
+ peersConnected: info.num_leechs,
+ peersTotal: info.num_incomplete,
+ percentComplete: Math.trunc(info.progress * 100),
+ priority: 1,
+ ratio: info.ratio,
+ seedsConnected: info.num_seeds,
+ seedsTotal: info.num_complete,
+ sizeBytes: info.size,
+ status: getTorrentStatusFromState(info.state),
+ tags: info.tags === '' ? [] : info.tags.split(','),
+ trackerURIs: getDomainsFromURLs([info.tracker]),
+ upRate: info.upspeed,
+ upTotal: info.uploaded,
+ };
+
+ 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 {
+ return this.clientRequestManager
+ .getTransferInfo()
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((info) => {
+ this.emit('PROCESS_TRANSFER_RATE_START');
+ return {
+ downRate: info.dl_info_speed,
+ downThrottle: info.dl_rate_limit,
+ downTotal: info.dl_info_data,
+ upRate: info.up_info_speed,
+ upThrottle: info.up_rate_limit,
+ upTotal: info.up_info_data,
+ };
+ });
+ }
+
+ async getClientSettings(): Promise {
+ return this.clientRequestManager
+ .getAppPreferences()
+ .then(this.processClientRequestSuccess, this.processClientRequestError)
+ .then((preferences) => {
+ return {
+ dht: preferences.dht,
+ dhtPort: preferences.listen_port,
+ dhtStats: {
+ active: 0,
+ buckets: 0,
+ bytes_read: 0,
+ bytes_written: 0,
+ cycle: 0,
+ errors_caught: 0,
+ errors_received: 0,
+ nodes: 0,
+ peers: 0,
+ peers_max: 0,
+ queries_received: 0,
+ queries_sent: 0,
+ replies_received: 0,
+ throttle: '',
+ torrents: 0,
+ },
+ directoryDefault: preferences.save_path.split(',')[0],
+ networkHttpMaxOpen: preferences.max_connec,
+ networkLocalAddress: [preferences.announce_ip],
+ networkMaxOpenFiles: 0,
+ networkPortOpen: true,
+ networkPortRandom: preferences.random_port,
+ networkPortRange: `${preferences.listen_port}`,
+ piecesHashOnCompletion: false,
+ piecesMemoryMax: 0,
+ protocolPex: preferences.pex,
+ throttleGlobalDownMax: preferences.dl_limit,
+ throttleGlobalUpMax: preferences.up_limit,
+ throttleMaxPeersNormal: 0,
+ throttleMaxPeersSeed: 0,
+ throttleMaxDownloads: 0,
+ throttleMaxDownloadsDiv: 0,
+ throttleMaxDownloadsGlobal: 0,
+ throttleMaxUploads: preferences.max_uploads_per_torrent,
+ throttleMaxUploadsDiv: 0,
+ throttleMaxUploadsGlobal: preferences.max_uploads,
+ throttleMinPeersNormal: 0,
+ throttleMinPeersSeed: 0,
+ trackersNumWant: 0,
+ };
+ });
+ }
+
+ async setClientSettings(settings: SetClientSettingsOptions): Promise {
+ return this.clientRequestManager.setAppPreferences({
+ dht: settings.dht,
+ save_path: settings.directoryDefault,
+ max_connec: settings.networkHttpMaxOpen,
+ announce_ip: settings.networkLocalAddress ? settings.networkLocalAddress[0] : undefined,
+ random_port: settings.networkPortRandom,
+ listen_port: settings.networkPortRange ? Number(settings.networkPortRange?.split('-')[0]) : undefined,
+ pex: settings.protocolPex,
+ dl_limit: settings.throttleGlobalDownMax,
+ up_limit: settings.throttleGlobalUpMax,
+ max_uploads_per_torrent: settings.throttleMaxUploads,
+ max_uploads: settings.throttleMaxUploadsGlobal,
+ });
+ }
+
+ async testGateway(clientSettings?: ClientConnectionSettings): Promise {
+ if (clientSettings != null && clientSettings.client !== 'qBittorrent') {
+ return;
+ }
+
+ if (!(await this.clientRequestManager.authenticate(clientSettings))) {
+ throw new Error();
+ }
+ }
+}
+
+export default QBittorrentClientGatewayService;
diff --git a/server/services/qBittorrent/clientRequestManager.ts b/server/services/qBittorrent/clientRequestManager.ts
new file mode 100644
index 00000000..54467480
--- /dev/null
+++ b/server/services/qBittorrent/clientRequestManager.ts
@@ -0,0 +1,270 @@
+import axios from 'axios';
+import FormData from 'form-data';
+
+import type {QBittorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings';
+
+import type {QBittorrentAppPreferences} from './types/QBittorrentAppMethods';
+import type {QBittorrentSyncTorrentPeers} from './types/QBittorrentSyncMethods';
+import type {QBittorrentTransferInfo} from './types/QBittorrentTransferMethods';
+import type {
+ QBittorrentTorrentContentPriority,
+ QBittorrentTorrentContents,
+ QBittorrentTorrentInfos,
+ QBittorrentTorrentsAddOptions,
+ QBittorrentTorrentTrackers,
+} from './types/QBittorrentTorrentsMethods';
+
+class ClientRequestManager {
+ connectionSettings: QBittorrentConnectionSettings;
+ apiBase: string;
+ authCookie?: Promise;
+
+ async authenticate(connectionSettings?: QBittorrentConnectionSettings): Promise {
+ let {url, username, password} = this.connectionSettings;
+
+ if (connectionSettings != null) {
+ url = connectionSettings.url;
+ username = connectionSettings.username;
+ password = connectionSettings.password;
+ }
+
+ this.authCookie = axios.get(`${url}/api/v2/auth/login?username=${username}&password=${password}`).then(
+ (res) => {
+ const cookies: Array = res.headers['set-cookie'];
+
+ if (Array.isArray(cookies)) {
+ return cookies.filter((cookie) => cookie.includes('SID='))[0];
+ }
+
+ return undefined;
+ },
+ () => {
+ return undefined;
+ },
+ );
+
+ await this.authCookie;
+
+ if (this.authCookie != null) {
+ return true;
+ }
+
+ setTimeout(this.authenticate, 5000);
+
+ return false;
+ }
+
+ async getAppPreferences(): Promise {
+ return axios
+ .get(`${this.apiBase}/app/preferences`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data);
+ }
+
+ async setAppPreferences(preferences: Partial): Promise {
+ return axios
+ .post(`${this.apiBase}/app/setPreferences`, `json=${JSON.stringify(preferences)}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async getTorrentInfos(): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/info`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data);
+ }
+
+ async getTorrentContents(hash: string): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/files?hash=${hash}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data);
+ }
+
+ async getTorrentTrackers(hash: string): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/trackers?hash=${hash}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data);
+ }
+
+ async getTransferInfo(): Promise {
+ return axios
+ .get(`${this.apiBase}/transfer/info`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data);
+ }
+
+ async syncTorrentPeers(hash: string): Promise {
+ return axios
+ .get(`${this.apiBase}/sync/torrentPeers?hash=${hash}&rid=${Date.now()}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then((json) => json.data.peers);
+ }
+
+ async torrentsPause(hashes: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/pause?hashes=${hashes.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsResume(hashes: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/resume?hashes=${hashes.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsDelete(hashes: Array, deleteFiles: boolean): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/delete?hashes=${hashes.join('|')}&deleteFiles=${deleteFiles}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsRecheck(hashes: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/recheck?hashes=${hashes.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsSetLocation(hashes: Array, location: string): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/setLocation?hashes=${hashes.join('|')}&location=${location}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsSetTopPrio(hashes: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/topPrio?hashes=${hashes.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsSetBottomPrio(hashes: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/bottomPrio?hashes=${hashes.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsAddFiles(files: Array, options: QBittorrentTorrentsAddOptions): Promise {
+ const form = new FormData();
+
+ files.forEach((file, index) => {
+ form.append('torrents', file, {
+ filename: `${index}.torrent`,
+ contentType: 'application/x-bittorrent',
+ });
+ });
+
+ Object.keys(options).forEach((key) => {
+ const property = key as keyof typeof options;
+ form.append(property, `${options[property]}`);
+ });
+
+ const headers = form.getHeaders({Cookie: await this.authCookie, 'Content-Length': form.getLengthSync()});
+
+ axios
+ .post(`${this.apiBase}/torrents/add`, form, {
+ headers,
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsAddURLs(urls: Array, options: QBittorrentTorrentsAddOptions): Promise {
+ const form = new FormData();
+
+ form.append('urls', urls.join('\n'));
+
+ Object.keys(options).forEach((key) => {
+ const property = key as keyof typeof options;
+ form.append(property, `${options[property]}`);
+ });
+
+ const headers = form.getHeaders({Cookie: await this.authCookie, 'Content-Length': form.getLengthSync()});
+
+ axios
+ .post(`${this.apiBase}/torrents/add`, form, {
+ headers,
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsAddTags(hashes: Array, tags: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/addTags?hashes=${hashes.join('|')}&tags=${tags.join(',')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsAddTrackers(hash: string, urls: Array): Promise {
+ return axios
+ .get(`${this.apiBase}/torrents/addTrackers?hash=${hash}&urls=${urls.join('|')}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ async torrentsFilePrio(hash: string, ids: Array, priority: QBittorrentTorrentContentPriority) {
+ return axios
+ .get(`${this.apiBase}/torrents/filePrio?hash=${hash}&id=${ids.join('|')}&priority=${priority}`, {
+ headers: {Cookie: await this.authCookie},
+ })
+ .then(() => {
+ // returns nothing
+ });
+ }
+
+ constructor(connectionSettings: QBittorrentConnectionSettings) {
+ this.connectionSettings = connectionSettings;
+ this.apiBase = `${connectionSettings.url}/api/v2`;
+
+ this.authenticate();
+ }
+}
+
+export default ClientRequestManager;
diff --git a/server/services/qBittorrent/types/QBittorrentAppMethods.ts b/server/services/qBittorrent/types/QBittorrentAppMethods.ts
new file mode 100644
index 00000000..94ecfd14
--- /dev/null
+++ b/server/services/qBittorrent/types/QBittorrentAppMethods.ts
@@ -0,0 +1,24 @@
+export interface QBittorrentAppPreferences {
+ dht: boolean;
+ pex: boolean;
+ // Default save path for torrents, separated by slashes
+ save_path: string;
+ // Maximum global number of simultaneous connections
+ max_connec: number;
+ // Maximum number of simultaneous connections per torrent
+ max_connec_per_torrent: number;
+ // Maximum number of upload slots
+ max_uploads: number;
+ // Maximum number of upload slots per torrent
+ max_uploads_per_torrent: number;
+ // IP announced to trackers
+ announce_ip: string;
+ // Port for incoming connections
+ listen_port: number;
+ // True if the port is randomly selected
+ random_port: boolean;
+ // Global download speed limit in KiB/s; `-1` means no limit is applied
+ dl_limit: number;
+ // Global upload speed limit in KiB/s; `-1` means no limit is applied
+ up_limit: number;
+}
diff --git a/server/services/qBittorrent/types/QBittorrentSyncMethods.ts b/server/services/qBittorrent/types/QBittorrentSyncMethods.ts
new file mode 100644
index 00000000..691adc7a
--- /dev/null
+++ b/server/services/qBittorrent/types/QBittorrentSyncMethods.ts
@@ -0,0 +1,21 @@
+export interface QBittorrentSyncTorrentPeer {
+ client: string;
+ connection: string;
+ country: string;
+ country_code: string;
+ dl_speed: number;
+ downloaded: number;
+ up_speed: number;
+ uploaded: number;
+ files: string;
+ flags: string;
+ flags_desc: string;
+ ip: string;
+ port: number;
+ progress: number;
+ relevance: number;
+}
+
+export type QBittorrentSyncTorrentPeers = {
+ [ip_and_port: string]: QBittorrentSyncTorrentPeer;
+};
diff --git a/server/services/qBittorrent/types/QBittorrentTorrentsMethods.ts b/server/services/qBittorrent/types/QBittorrentTorrentsMethods.ts
new file mode 100644
index 00000000..25faa84b
--- /dev/null
+++ b/server/services/qBittorrent/types/QBittorrentTorrentsMethods.ts
@@ -0,0 +1,198 @@
+export type QBittorrentTorrentState =
+ | 'error'
+ | 'missingFiles'
+ | 'uploading'
+ | 'pausedUP'
+ | 'queuedUP'
+ | 'stalledUP'
+ | 'checkingUP'
+ | 'forcedUP'
+ | 'allocating'
+ | 'downloading'
+ | 'metaDL'
+ | 'pausedDL'
+ | 'queuedDL'
+ | 'stalledDL'
+ | 'checkingDL'
+ | 'forceDL'
+ | 'checkingResumeData'
+ | 'moving'
+ | 'unknown';
+
+export interface QBittorrentTorrentInfo {
+ // Time (Unix Epoch) when the torrent was added to the client
+ added_on: number;
+ // Amount of data left to download (bytes)
+ amount_left: number;
+ // Whether this torrent is managed by Automatic Torrent Management
+ auto_tmm: boolean;
+ // Percentage of file pieces currently available
+ availability: number;
+ // Category of the torrent
+ category: string;
+ // Amount of transfer data completed (bytes)
+ completed: number;
+ // Time (Unix Epoch) when the torrent completed
+ completion_on: number;
+ // Torrent download speed limit (bytes/s). -1 if unlimited.
+ dl_limit: number;
+ // Torrent download speed (bytes/s)
+ dlspeed: number;
+ // Amount of data downloaded
+ downloaded: number;
+ // Amount of data downloaded this session
+ downloaded_session: number;
+ // Torrent ETA (seconds)
+ eta: number;
+ // True if first last piece are prioritized
+ f_l_piece_prio: boolean;
+ // True if force start is enabled for this torrent
+ force_start: boolean;
+ // Torrent hash
+ hash: string;
+ // Last time (Unix Epoch) when a chunk was downloaded/uploaded
+ last_activity: number;
+ // Magnet URI corresponding to this torrent
+ magnet_uri: string;
+ // Maximum share ratio until torrent is stopped from seeding/uploading
+ max_ratio: number;
+ // Maximum seeding time (seconds) until torrent is stopped from seeding
+ max_seeding_time: number;
+ // Torrent name
+ name: string;
+ // Number of seeds in the swarm
+ num_complete: number;
+ // Number of leechers in the swarm
+ num_incomplete: number;
+ // Number of leechers connected to
+ num_leechs: number;
+ // Number of seeds connected to
+ num_seeds: number;
+ // Torrent priority. Returns -1 if queuing is disabled or torrent is in seed mode
+ priority: number;
+ // Torrent progress (percentage/100)
+ progress: number;
+ // Torrent share ratio. Max ratio value: 9999.
+ ratio: number;
+ // TODO (what is different from max_ratio?)
+ ratio_limit: number;
+ // Path where this torrent's data is stored
+ save_path: string;
+ // TODO (what is different from max_seeding_time?)
+ seeding_time_limit: number;
+ // Time (Unix Epoch) when this torrent was last seen complete
+ seen_complete: number;
+ // True if sequential download is enabled
+ seq_dl: boolean;
+ // Total size (bytes) of files selected for download
+ size: number;
+ // Torrent state
+ state: QBittorrentTorrentState;
+ // True if super seeding is enabled
+ super_seeding: boolean;
+ // Comma-concatenated tag list of the torrent
+ tags: string;
+ // Total active time (seconds)
+ time_active: number;
+ // Total size (bytes) of all file in this torrent (including unselected ones)
+ total_size: number;
+ // The first tracker with working status. Returns empty string if no tracker is working.
+ tracker: string;
+ // Torrent upload speed limit (bytes/s). -1 if unlimited.
+ up_limit: number;
+ // Amount of data uploaded
+ uploaded: number;
+ // Amount of data uploaded this session
+ uploaded_session: number;
+ // Torrent upload speed (bytes/s)
+ upspeed: number;
+}
+
+export type QBittorrentTorrentInfos = Array;
+
+export interface QBittorrentTorrentsAddOptions {
+ // Download folder
+ savepath?: string;
+ // Cookie sent to download the .torrent file
+ cookie?: string;
+ // Category for the torrent
+ category?: string;
+ // Skip hash checking. Possible values are true, false (default)
+ skip_checking?: boolean;
+ // Add torrents in the paused state. Possible values are true, false (default)
+ paused?: boolean;
+ // Create the root folder. Possible values are true, false, unset (default)
+ root_folder?: boolean;
+ // Rename torrent
+ rename?: string;
+ // Set torrent upload speed limit. Unit in bytes/second
+ upLimit?: number;
+ // Set torrent download speed limit. Unit in bytes/second
+ dlLimit?: number;
+ // Whether Automatic Torrent Management should be used
+ autoTMM?: boolean;
+ // Enable sequential download. Possible values are true, false (default)
+ sequentialDownload?: boolean;
+ // Prioritize download first last piece. Possible values are true, false (default)
+ firstLastPiecePrio?: boolean;
+}
+
+export enum QBittorrentTorrentContentPriority {
+ DO_NOT_DOWNLOAD = 0,
+ NORMAL = 1,
+ HIGH = 6,
+ MAXIMUM = 7,
+}
+
+export interface QBittorrentTorrentContent {
+ // File name (including relative path)
+ name: string;
+ // File size (bytes)
+ size: number;
+ // File progress (percentage/100)
+ progress: number;
+ // File priority
+ priority: QBittorrentTorrentContentPriority;
+ // True if file is seeding/complete
+ is_seed: boolean;
+ // The first number is the starting piece index and the second number is the ending piece index (inclusive)
+ piece_range: Array;
+ // Percentage of file pieces currently available
+ availability: number;
+}
+
+export type QBittorrentTorrentContents = Array;
+
+export enum QBittorrentTorrentTrackerStatus {
+ // Tracker is disabled (used for DHT, PeX, and LSD)
+ DISABLED = 0,
+ // Tracker has not been contacted yet
+ NOT_CONTACTED = 1,
+ // Tracker has been contacted and is working
+ CONTACTED = 2,
+ // Tracker is updating
+ UPDATING = 3,
+ // Tracker has been contacted, but it is not working (or doesn't send proper replies)
+ ERROR = 4,
+}
+
+export interface QBittorrentTorrentTracker {
+ // Tracker url
+ url: string;
+ // Tracker status
+ status: QBittorrentTorrentTrackerStatus;
+ // Tracker priority tier. Lower tier trackers are tried before higher tiers
+ tier: number;
+ // Number of peers for current torrent, as reported by the tracker
+ num_peers: number;
+ // Number of seeds for current torrent, as reported by the tracker
+ num_seeds: number;
+ // Number of leeches for current torrent, as reported by the tracker
+ num_leeches: number;
+ // Number of completed downloads for current torrent, as reported by the tracker
+ num_downloaded: number;
+ // Tracker message (there is no way of knowing what this message is - it's up to tracker admins)
+ msg: string;
+}
+
+export type QBittorrentTorrentTrackers = Array;
diff --git a/server/services/qBittorrent/types/QBittorrentTransferMethods.ts b/server/services/qBittorrent/types/QBittorrentTransferMethods.ts
new file mode 100644
index 00000000..cad5a3af
--- /dev/null
+++ b/server/services/qBittorrent/types/QBittorrentTransferMethods.ts
@@ -0,0 +1,18 @@
+export interface QBittorrentTransferInfo {
+ // Global download rate (bytes/s)
+ dl_info_speed: number;
+ // Data downloaded this session (bytes)
+ dl_info_data: number;
+ // Global upload rate (bytes/s)
+ up_info_speed: number;
+ // Data uploaded this session (bytes)
+ up_info_data: number;
+ // Download rate limit (bytes/s)
+ dl_rate_limit: number;
+ // Upload rate limit (bytes/s)
+ up_rate_limit: number;
+ // DHT nodes connected to
+ dht_nodes: number;
+ // Connection status
+ connection_status: 'connected' | 'firewalled' | 'disconnected';
+}
diff --git a/server/services/qBittorrent/util/torrentPropertiesUtil.ts b/server/services/qBittorrent/util/torrentPropertiesUtil.ts
new file mode 100644
index 00000000..b3a91bd9
--- /dev/null
+++ b/server/services/qBittorrent/util/torrentPropertiesUtil.ts
@@ -0,0 +1,95 @@
+import {TorrentPeer} from '../../../../shared/types/TorrentPeer';
+import {TorrentTrackerType} from '../../../../shared/types/TorrentTracker';
+
+import type {QBittorrentTorrentState} from '../types/QBittorrentTorrentsMethods';
+import type {TorrentProperties} from '../../../../shared/types/Torrent';
+import type {TorrentTracker} from '../../../../shared/types/TorrentTracker';
+
+export const getTorrentPeerPropertiesFromFlags = (flags: string): Pick => {
+ const flagsArray = flags.split(' ');
+
+ return {
+ isEncrypted: flagsArray.includes('E'),
+ isIncoming: flagsArray.includes('I'),
+ };
+};
+
+export const getTorrentTrackerTypeFromURL = (url: string): TorrentTracker['type'] => {
+ if (url.startsWith('http')) {
+ return TorrentTrackerType.HTTP;
+ }
+
+ if (url.startsWith('udp')) {
+ return TorrentTrackerType.UDP;
+ }
+
+ return TorrentTrackerType.DHT;
+};
+
+export const getTorrentStatusFromState = (state: QBittorrentTorrentState): TorrentProperties['status'] => {
+ const statuses: TorrentProperties['status'] = [];
+
+ switch (state) {
+ case 'error':
+ case 'missingFiles':
+ statuses.push('error');
+ statuses.push('inactive');
+ statuses.push('stopped');
+ break;
+ case 'uploading':
+ statuses.push('complete');
+ statuses.push('active');
+ statuses.push('seeding');
+ statuses.push('activelyUploading');
+ break;
+ case 'pausedUP':
+ statuses.push('complete');
+ statuses.push('inactive');
+ statuses.push('stopped');
+ break;
+ case 'queuedUP':
+ case 'stalledUP':
+ case 'forcedUP':
+ statuses.push('complete');
+ statuses.push('inactive');
+ statuses.push('seeding');
+ break;
+ case 'checkingUP':
+ statuses.push('complete');
+ statuses.push('active');
+ statuses.push('checking');
+ break;
+ case 'allocating':
+ statuses.push('downloading');
+ break;
+ case 'metaDL':
+ case 'downloading':
+ statuses.push('active');
+ statuses.push('downloading');
+ statuses.push('activelyDownloading');
+ break;
+ case 'pausedDL':
+ statuses.push('inactive');
+ statuses.push('stopped');
+ break;
+ case 'queuedDL':
+ case 'stalledDL':
+ case 'forceDL':
+ statuses.push('inactive');
+ statuses.push('downloading');
+ break;
+ case 'checkingDL':
+ statuses.push('active');
+ statuses.push('checking');
+ break;
+ case 'moving':
+ case 'checkingResumeData':
+ case 'unknown':
+ statuses.push('checking');
+ break;
+ default:
+ break;
+ }
+
+ return statuses;
+};
diff --git a/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts b/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts
index 163786c7..33792d16 100644
--- a/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts
+++ b/server/services/rTorrent/constants/methodCallConfigs/torrentList.ts
@@ -1,4 +1,4 @@
-import regEx from '../../../../../shared/util/regEx';
+import {getDomainsFromURLs} from '../../../../util/torrentPropertiesUtil';
import {stringTransformer, booleanTransformer, numberTransformer} from '../../util/rTorrentMethodCallUtil';
const torrentListMethodCallConfigs = {
@@ -119,40 +119,18 @@ const torrentListMethodCallConfigs = {
if (typeof value !== 'string') {
return [];
}
-
- const trackers = value.split('|||');
- const trackerDomains: Array = [];
-
- trackers.forEach((tracker) => {
- // Only count enabled trackers
- if (tracker.charAt(0) === '0') {
- return;
- }
-
- const regexMatched = regEx.domainName.exec(tracker.substr(1));
-
- if (regexMatched != null && regexMatched[1]) {
- let domain = regexMatched[1];
-
- const minSubsetLength = 3;
- const domainSubsets = domain.split('.');
- let desiredSubsets = 2;
-
- if (domainSubsets.length > desiredSubsets) {
- const lastDesiredSubset = domainSubsets[domainSubsets.length - desiredSubsets];
- if (lastDesiredSubset.length <= minSubsetLength) {
- desiredSubsets += 1;
- }
+ return getDomainsFromURLs(
+ value.split('|||').reduce((trackers: Array, tracker) => {
+ // Only count enabled trackers
+ if (tracker.charAt(0) === '0') {
+ return trackers;
}
- domain = domainSubsets.slice(desiredSubsets * -1).join('.');
+ trackers.push(tracker.substr(1));
- trackerDomains.push(domain);
- }
- });
-
- // Deduplicate
- return [...new Set(trackerDomains)];
+ return trackers;
+ }, []),
+ );
},
},
seedsConnected: {
diff --git a/server/services/torrentService.ts b/server/services/torrentService.ts
index 64c313b2..e60597e5 100644
--- a/server/services/torrentService.ts
+++ b/server/services/torrentService.ts
@@ -4,7 +4,7 @@ import type {TorrentProperties, TorrentListSummary} from '@shared/types/Torrent'
import BaseService from './BaseService';
import config from '../../config';
-import hasTorrentFinished from '../util/torrentPropertiesUtil';
+import {hasTorrentFinished} from '../util/torrentPropertiesUtil';
interface TorrentServiceEvents {
FETCH_TORRENT_LIST_SUCCESS: () => void;
diff --git a/server/util/torrentPropertiesUtil.ts b/server/util/torrentPropertiesUtil.ts
index 69341534..59dc5764 100644
--- a/server/util/torrentPropertiesUtil.ts
+++ b/server/util/torrentPropertiesUtil.ts
@@ -1,6 +1,8 @@
+import regEx from '../../shared/util/regEx';
+
import type {TorrentProperties} from '../../shared/types/Torrent';
-const hasTorrentFinished = (
+export const hasTorrentFinished = (
prevData: Partial = {},
nextData: Partial = {},
): boolean => {
@@ -19,4 +21,32 @@ const hasTorrentFinished = (
return false;
};
-export default hasTorrentFinished;
+export const getDomainsFromURLs = (urls: Array): Array => {
+ const domains: Array = [];
+
+ urls.forEach((url) => {
+ const regexMatched = regEx.domainName.exec(url);
+
+ if (regexMatched != null && regexMatched[1]) {
+ let domain = regexMatched[1];
+
+ const minSubsetLength = 3;
+ const domainSubsets = domain.split('.');
+ let desiredSubsets = 2;
+
+ if (domainSubsets.length > desiredSubsets) {
+ const lastDesiredSubset = domainSubsets[domainSubsets.length - desiredSubsets];
+ if (lastDesiredSubset.length <= minSubsetLength) {
+ desiredSubsets += 1;
+ }
+ }
+
+ domain = domainSubsets.slice(desiredSubsets * -1).join('.');
+
+ domains.push(domain);
+ }
+ });
+
+ // Deduplicate
+ return [...new Set(domains)];
+};
diff --git a/shared/schema/ClientConnectionSettings.ts b/shared/schema/ClientConnectionSettings.ts
index a301cafa..b63d3645 100644
--- a/shared/schema/ClientConnectionSettings.ts
+++ b/shared/schema/ClientConnectionSettings.ts
@@ -58,6 +58,11 @@ const transmissionConnectionSettingsSchema = z.object({
export type TransmissionConnectionSettings = z.infer;
-export const clientConnectionSettingsSchema = rTorrentConnectionSettingsSchema;
+export const clientConnectionSettingsSchema = z.union([
+ qBittorrentConnectionSettingsSchema,
+ rTorrentConnectionSettingsSchema,
+]);
export type ClientConnectionSettings = z.infer;
+
+export const SUPPORTED_CLIENTS: Array = ['qBittorrent', 'rTorrent'];