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'];