diff --git a/client/src/javascript/actions/ClientActions.ts b/client/src/javascript/actions/ClientActions.ts index 3b569c86..cc018a88 100644 --- a/client/src/javascript/actions/ClientActions.ts +++ b/client/src/javascript/actions/ClientActions.ts @@ -1,13 +1,13 @@ import axios from 'axios'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {SetClientSettingsOptions} from '@shared/types/api/client'; import type {TransferDirection} from '@shared/types/TransferData'; import AppDispatcher from '../dispatcher/AppDispatcher'; import ConfigStore from '../stores/ConfigStore'; import type {ClientSettingsSaveSuccessAction} from '../constants/ServerActions'; -import type {SettingUpdatesClient} from '../stores/SettingsStore'; const baseURI = ConfigStore.getBaseURI(); @@ -31,7 +31,7 @@ const ClientActions = { }, ), - saveSettings: (settings: SettingUpdatesClient, options: ClientSettingsSaveSuccessAction['options']) => + saveSettings: (settings: SetClientSettingsOptions, options: ClientSettingsSaveSuccessAction['options']) => axios .patch(`${baseURI}api/client/settings`, settings) .then((json) => json.data) diff --git a/client/src/javascript/components/modals/settings-modal/SettingsModal.js b/client/src/javascript/components/modals/settings-modal/SettingsModal.js index 24960355..bcc274dd 100644 --- a/client/src/javascript/components/modals/settings-modal/SettingsModal.js +++ b/client/src/javascript/components/modals/settings-modal/SettingsModal.js @@ -53,18 +53,13 @@ class SettingsModal extends React.Component { data: this.state.changedFloodSettings[settingsKey], })); - const clientSettings = Object.keys(this.state.changedClientSettings).map((settingsKey) => ({ - id: settingsKey, - data: this.state.changedClientSettings[settingsKey], - })); - this.setState({isSavingSettings: true}, () => { Promise.all([ SettingsStore.saveFloodSettings(floodSettings, { dismissModal: true, alert: true, }), - SettingsStore.saveClientSettings(clientSettings, { + SettingsStore.saveClientSettings(this.state.changedClientSettings, { dismissModal: true, alert: true, }), diff --git a/client/src/javascript/constants/ServerActions.ts b/client/src/javascript/constants/ServerActions.ts index bd440f79..1793b184 100644 --- a/client/src/javascript/constants/ServerActions.ts +++ b/client/src/javascript/constants/ServerActions.ts @@ -1,6 +1,6 @@ import type {AuthAuthenticationResponse, AuthVerificationResponse} from '@shared/schema/api/auth'; import type {Credentials} from '@shared/schema/Auth'; -import type {ClientSettings} from '@shared/constants/clientSettingsMap'; +import type {ClientSettings} from '@shared/types/ClientSettings'; import type {NotificationFetchOptions, NotificationState} from '@shared/types/Notification'; import type {ServerEvents} from '@shared/types/ServerEvents'; import type {TorrentDetails} from '@shared/types/Torrent'; diff --git a/client/src/javascript/stores/SettingsStore.ts b/client/src/javascript/stores/SettingsStore.ts index c146a7c0..c01c7998 100644 --- a/client/src/javascript/stores/SettingsStore.ts +++ b/client/src/javascript/stores/SettingsStore.ts @@ -1,4 +1,4 @@ -import type {ClientSetting, ClientSettings} from '@shared/constants/clientSettingsMap'; +import type {ClientSetting, ClientSettings} from '@shared/types/ClientSettings'; import AlertStore from './AlertStore'; import AppDispatcher from '../dispatcher/AppDispatcher'; @@ -61,7 +61,7 @@ class SettingsStoreClass extends BaseStore { floodSettingsFetched: false, }; - clientSettings: ClientSettings = {}; + clientSettings: ClientSettings | null = null; // Default settings are overridden by settings stored in database. floodSettings: FloodSettings = { @@ -113,11 +113,17 @@ class SettingsStoreClass extends BaseStore { mountPoints: [], }; - getClientSetting(property: T): ClientSettings[T] { + getClientSetting(property: T): ClientSettings[T] | null { + if (this.clientSettings == null) { + return null; + } return this.clientSettings[property]; } - getClientSettings(): ClientSettings { + getClientSettings(): ClientSettings | null { + if (this.clientSettings == null) { + return null; + } return this.clientSettings; } @@ -210,19 +216,9 @@ class SettingsStoreClass extends BaseStore { this.emit('SETTINGS_CHANGE'); } - saveClientSettings(settings: SettingUpdatesClient, options: SettingsSaveOptions = {}) { + saveClientSettings(settings: Partial, options: SettingsSaveOptions = {}) { ClientActions.saveSettings(settings, options); - settings.forEach(

({id, data}: {id: P; data: V}) => { - if (id === 'dht') { - // Special case: - // DHT mode uses different set and get methods. (rTorrent's problem) - // TODO: This is cleaner than previous solution but it is still dirty. - // It is totally nonsense that dht.mode.set sets DHT mode but dht.mode doesn't work. - this.clientSettings.dhtStats = {dht: data}; - return; - } - this.clientSettings[id] = data; - }); + Object.assign(this.clientSettings, settings); this.emit('SETTINGS_CHANGE'); } @@ -231,7 +227,7 @@ class SettingsStoreClass extends BaseStore { }; setClientSetting = (property: T, data: ClientSettings[T]) => { - this.saveClientSettings([{id: property, data}]); + this.saveClientSettings({[property]: data}); }; } diff --git a/server/models/ClientRequest.js b/server/models/ClientRequest.js index c8a81c85..a8f60111 100644 --- a/server/models/ClientRequest.js +++ b/server/models/ClientRequest.js @@ -3,8 +3,6 @@ */ import util from 'util'; -import {clientSettingsMap} from '../../shared/constants/clientSettingsMap'; - const getEnsuredArray = (item) => { if (!util.isArray(item)) { return [item]; @@ -90,31 +88,6 @@ class ClientRequest { this.clientRequestManager.methodCall('system.multicall', [this.requests]).then(handleSuccess).catch(handleError); } - fetchSettings(options) { - let {requestedSettings} = options; - - if (requestedSettings == null) { - requestedSettings = Object.values(clientSettingsMap); - } - - // Ensure client's response gets mapped to the correct requested keys. - if (options.setRequestedKeysArr) { - options.setRequestedKeysArr(requestedSettings); - } - - requestedSettings.forEach((settingsKey) => { - this.requests.push(getMethodCall(settingsKey)); - }); - } - - setSettings(options) { - const settings = getEnsuredArray(options.settings); - - settings.forEach((setting) => { - this.requests.push(getMethodCall(`${clientSettingsMap[setting.id]}.set`, ['', setting.data])); - }); - } - setTracker(options) { const existingTrackerIndex = 0; const {tracker} = options; diff --git a/server/models/client.js b/server/models/client.js index d87f0b17..94ffb72d 100644 --- a/server/models/client.js +++ b/server/models/client.js @@ -5,7 +5,6 @@ import {series} from 'async'; import tar from 'tar-stream'; import ClientRequest from './ClientRequest'; -import {clientSettingsBiMap} from '../../shared/constants/clientSettingsMap'; import torrentFileUtil from '../util/torrentFileUtil'; const client = { @@ -89,79 +88,6 @@ const client = { return selectedFiles; }, - getSettings(user, services, options, callback) { - let requestedSettingsKeys = []; - const request = new ClientRequest(user, services); - const response = {}; - - const outboundTransformation = { - throttleGlobalDownMax: (apiResponse) => Number(apiResponse) / 1024, - throttleGlobalUpMax: (apiResponse) => Number(apiResponse) / 1024, - piecesMemoryMax: (apiResponse) => Number(apiResponse) / (1024 * 1024), - }; - - request.fetchSettings({ - options, - setRequestedKeysArr: (requestedSettingsKeysArr) => { - requestedSettingsKeys = requestedSettingsKeysArr; - }, - }); - - request.postProcess((data) => { - if (!data) { - return null; - } - - data.forEach((datum, index) => { - let value = datum[0]; - const settingsKey = clientSettingsBiMap[requestedSettingsKeys[index]]; - - if (outboundTransformation[settingsKey]) { - value = outboundTransformation[settingsKey](value); - } - - response[settingsKey] = value; - }); - - return response; - }); - request.onComplete(callback); - request.send(); - }, - - setSettings(user, services, payloads, callback) { - const request = new ClientRequest(user, services); - if (payloads.length === 0) return callback({}); - - const inboundTransformations = new Map(); - inboundTransformations - .set('throttleGlobalDownMax', (userInput) => ({ - id: userInput.id, - data: Number(userInput.data) * 1024, - })) - .set('throttleGlobalUpMax', (userInput) => ({ - id: userInput.id, - data: Number(userInput.data) * 1024, - })) - .set('piecesMemoryMax', (userInput) => ({ - id: userInput.id, - data: (Number(userInput.data) * 1024 * 1024).toString(), - })); - - const transformedPayloads = payloads.map((payload) => { - if (inboundTransformations.has(payload.id)) { - const inboundTransformation = inboundTransformations.get(payload.id); - return inboundTransformation(payload); - } - - return payload; - }); - - request.setSettings({settings: transformedPayloads}); - request.onComplete(callback); - request.send(); - }, - setSpeedLimits(user, services, data, callback) { const request = new ClientRequest(user, services); diff --git a/server/routes/api/client.ts b/server/routes/api/client.ts index 7371e769..17ffbfa0 100644 --- a/server/routes/api/client.ts +++ b/server/routes/api/client.ts @@ -1,6 +1,7 @@ import express from 'express'; import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import type {SetClientSettingsOptions} from '@shared/types/api/client'; import ajaxUtil from '../../util/ajaxUtil'; import client from '../../models/client'; @@ -62,12 +63,39 @@ router.post('/connection-test', (req }); }); +/** + * GET /api/client/settings + * @summary Gets settings of torrent client managed by Flood. + * @tags Client + * @security AuthenticatedUser + * @return {ClientSettings} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ router.get('/settings', (req, res) => { - client.getSettings(req.user, req.services, req.query, ajaxUtil.getResponseFn(res)); + const callback = ajaxUtil.getResponseFn(res); + + req.services?.clientGatewayService + .getClientSettings() + .then(callback) + .catch((e) => callback(null, e)); }); -router.patch('/settings', (req, res) => { - client.setSettings(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); +/** + * PATCH /api/client/settings + * @summary Sets settings of torrent client managed by Flood. + * @tags Client + * @security AuthenticatedUser + * @param {SetClientSettingsOptions} request.body.required - options - application/json + * @return {object} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.patch('/settings', (req, res) => { + const callback = ajaxUtil.getResponseFn(res); + + req.services?.clientGatewayService + .setClientSettings(req.body) + .then(callback) + .catch((e) => callback(null, e)); }); export default router; diff --git a/server/services/rTorrent/clientGatewayService.ts b/server/services/rTorrent/clientGatewayService.ts index 74d520c4..be6a8d9f 100644 --- a/server/services/rTorrent/clientGatewayService.ts +++ b/server/services/rTorrent/clientGatewayService.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import geoip from 'geoip-country'; import {moveSync} from 'fs-extra'; +import type {ClientSettings} from '@shared/types/ClientSettings'; import type {RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; import type {TorrentContentTree} from '@shared/types/TorrentContent'; import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent'; @@ -21,6 +22,7 @@ import type { StartTorrentsOptions, StopTorrentsOptions, } from '@shared/types/api/torrents'; +import type {SetClientSettingsOptions} from '@shared/types/api/client'; import {accessDeniedError, createDirectory, isAllowedPath, sanitizePath} from '../../util/fileUtil'; import BaseService from '../BaseService'; @@ -34,6 +36,7 @@ import { getTorrentStatusFromProperties, } from './util/torrentPropertiesUtil'; import { + clientSettingMethodCallConfigs, torrentContentMethodCallConfigs, torrentListMethodCallConfigs, torrentPeerMethodCallConfigs, @@ -589,11 +592,91 @@ class ClientGatewayService extends BaseService { return ( this.services?.clientRequestManager .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess) + .then(this.processClientRequestSuccess, this.processClientRequestError) .then((response) => { this.emit('PROCESS_TRANSFER_RATE_START'); return processMethodCallResponse(response, configs); - }, this.processClientRequestError) || Promise.reject() + }) || Promise.reject() + ); + } + + /** + * Gets settings of rTorrent + * + * @return {Promise} - Resolves with ClientSettings or rejects with error. + */ + async getClientSettings(): Promise { + const configs = clientSettingMethodCallConfigs; + const methodCalls: MultiMethodCalls = getMethodCalls(configs).map((methodCall) => { + return { + methodName: methodCall, + params: [], + }; + }); + + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) + .then((response) => { + return processMethodCallResponse(response, configs); + }) + .then((processedResponse) => { + return { + dht: processedResponse.dhtStats.dht !== 'disable', + ...processedResponse, + }; + }) || Promise.reject() + ); + } + + /** + * Sets settings of rTorrent + * + * @param {SetClientSettingsOptions} - Settings to be set. + * @return {Promise} - Resolves with RPC call response or rejects with error. + */ + async setClientSettings(settings: SetClientSettingsOptions) { + const configs = clientSettingMethodCallConfigs; + const methodCalls = Object.keys(settings).reduce((accumulator: MultiMethodCalls, key) => { + const property = key as keyof SetClientSettingsOptions; + let methodName = ''; + let param = settings[property]; + + if (param == null) { + return accumulator; + } + + switch (property) { + case 'dht': + methodName = 'dht.mode.set'; + break; + case 'throttleGlobalDownMax': + case 'throttleGlobalUpMax': + methodName = `${configs[property].methodCall}.set`; + param = (param as ClientSettings[typeof property]) * 1024; + break; + case 'piecesMemoryMax': + methodName = `${configs[property].methodCall}.set`; + param = (param as ClientSettings[typeof property]) * 1024 * 1024; + break; + default: + methodName = `${configs[property].methodCall}.set`; + break; + } + + accumulator.push({ + methodName, + params: ['', `${param}`], + }); + + return accumulator; + }, []); + + return ( + this.services?.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess, this.processClientRequestError) || Promise.reject() ); } diff --git a/server/services/rTorrent/constants/methodCallConfigs/clientSetting.ts b/server/services/rTorrent/constants/methodCallConfigs/clientSetting.ts new file mode 100644 index 00000000..e2714f25 --- /dev/null +++ b/server/services/rTorrent/constants/methodCallConfigs/clientSetting.ts @@ -0,0 +1,147 @@ +import {numberTransformer, stringTransformer} from '../../util/rTorrentMethodCallUtil'; + +const clientSettingMethodCallConfigs = { + dhtPort: { + methodCall: 'dht.port', + transformValue: numberTransformer, + }, + dhtStats: { + methodCall: 'dht.statistics', + transformValue: (value: unknown) => { + const [stats] = value as Array>; + return { + active: Number(stats.active), + buckets: Number(stats.buckets), + bytes_read: Number(stats.bytes_read), + bytes_written: Number(stats.bytes_written), + cycle: Number(stats.cycle), + dht: stats.dht, + errors_caught: Number(stats.errors_caught), + errors_received: Number(stats.errors_received), + nodes: Number(stats.nodes), + peers: Number(stats.peers), + peers_max: Number(stats.peers_max), + queries_received: Number(stats.queries_received), + queries_sent: Number(stats.queries_sent), + replies_received: Number(stats.replies_received), + throttle: Number(stats.throttle), + torrents: Number(stats.torrents), + }; + }, + }, + directoryDefault: { + methodCall: 'directory.default', + transformValue: stringTransformer, + }, + networkHttpMaxOpen: { + methodCall: 'network.http.max_open', + transformValue: numberTransformer, + }, + networkLocalAddress: { + methodCall: 'network.local_address', + transformValue: stringTransformer, + }, + networkMaxOpenFiles: { + methodCall: 'network.max_open_files', + transformValue: numberTransformer, + }, + networkPortOpen: { + methodCall: 'network.port_open', + transformValue: (value: unknown) => { + const [portOpen] = value as Array; + return portOpen === '1'; + }, + }, + networkPortRandom: { + methodCall: 'network.port_random', + transformValue: (value: unknown) => { + const [portRandom] = value as Array; + return portRandom === '1'; + }, + }, + networkPortRange: { + methodCall: 'network.port_range', + transformValue: (value: unknown) => { + const [portRange] = value as Array; + return portRange; + }, + }, + piecesHashOnCompletion: { + methodCall: 'pieces.hash.on_completion', + transformValue: (value: unknown) => { + const [hashOnCompletion] = value as Array; + return hashOnCompletion === '1'; + }, + }, + piecesMemoryMax: { + methodCall: 'pieces.memory.max', + transformValue: (value: unknown) => { + return Number(value) / (1024 * 1024); + }, + }, + protocolPex: { + methodCall: 'protocol.pex', + transformValue: (value: unknown) => { + const [protocolPex] = value as Array; + return protocolPex === '1'; + }, + }, + throttleGlobalDownMax: { + methodCall: 'throttle.global_down.max_rate', + transformValue: (value: unknown) => { + return Number(value) / 1024; + }, + }, + throttleGlobalUpMax: { + methodCall: 'throttle.global_up.max_rate', + transformValue: (value: unknown) => { + return Number(value) / 1024; + }, + }, + throttleMaxPeersNormal: { + methodCall: 'throttle.max_peers.normal', + transformValue: numberTransformer, + }, + throttleMaxPeersSeed: { + methodCall: 'throttle.max_peers.seed', + transformValue: numberTransformer, + }, + throttleMaxDownloads: { + methodCall: 'throttle.max_downloads', + transformValue: numberTransformer, + }, + throttleMaxDownloadsDiv: { + methodCall: 'throttle.max_downloads.div', + transformValue: numberTransformer, + }, + throttleMaxDownloadsGlobal: { + methodCall: 'throttle.max_downloads.global', + transformValue: numberTransformer, + }, + throttleMaxUploads: { + methodCall: 'throttle.max_uploads', + transformValue: numberTransformer, + }, + throttleMaxUploadsDiv: { + methodCall: 'throttle.max_uploads.div', + transformValue: numberTransformer, + }, + throttleMaxUploadsGlobal: { + methodCall: 'throttle.max_uploads.global', + transformValue: numberTransformer, + }, + throttleMinPeersNormal: { + methodCall: 'throttle.min_peers.normal', + transformValue: numberTransformer, + }, + throttleMinPeersSeed: { + methodCall: 'throttle.min_peers.seed', + transformValue: numberTransformer, + }, + trackersNumWant: { + methodCall: 'trackers.numwant', + transformValue: numberTransformer, + }, +} as const; + +export default clientSettingMethodCallConfigs; diff --git a/server/services/rTorrent/constants/methodCallConfigs/index.ts b/server/services/rTorrent/constants/methodCallConfigs/index.ts index 79e9ea32..621e8228 100644 --- a/server/services/rTorrent/constants/methodCallConfigs/index.ts +++ b/server/services/rTorrent/constants/methodCallConfigs/index.ts @@ -1,3 +1,4 @@ +export {default as clientSettingMethodCallConfigs} from './clientSetting'; export {default as torrentContentMethodCallConfigs} from './torrentContent'; export {default as torrentListMethodCallConfigs} from './torrentList'; export {default as torrentPeerMethodCallConfigs} from './torrentPeer'; diff --git a/server/services/rTorrent/util/rTorrentMethodCallUtil.ts b/server/services/rTorrent/util/rTorrentMethodCallUtil.ts index fb5c3902..5b936c07 100644 --- a/server/services/rTorrent/util/rTorrentMethodCallUtil.ts +++ b/server/services/rTorrent/util/rTorrentMethodCallUtil.ts @@ -1,6 +1,6 @@ export interface MethodCallConfig { readonly methodCall: string; - readonly transformValue: (value: unknown) => string | boolean | number | string[]; + readonly transformValue: (value: unknown) => string | boolean | number | string[] | Record; } export type MethodCallConfigs = Readonly<{ diff --git a/shared/constants/clientSettingsMap.ts b/shared/constants/clientSettingsMap.ts deleted file mode 100644 index 76629727..00000000 --- a/shared/constants/clientSettingsMap.ts +++ /dev/null @@ -1,39 +0,0 @@ -import objectUtil from '../util/objectUtil'; - -export const clientSettingsMap = { - dht: 'dht.mode', - dhtPort: 'dht.port', - dhtStats: 'dht.statistics', - directoryDefault: 'directory.default', - networkHttpMaxOpen: 'network.http.max_open', - networkLocalAddress: 'network.local_address', - networkMaxOpenFiles: 'network.max_open_files', - networkPortOpen: 'network.port_open', - networkPortRandom: 'network.port_random', - networkPortRange: 'network.port_range', - piecesHashOnCompletion: 'pieces.hash.on_completion', - piecesMemoryMax: 'pieces.memory.max', - protocolPex: 'protocol.pex', - throttleGlobalDownMax: 'throttle.global_down.max_rate', - throttleGlobalUpMax: 'throttle.global_up.max_rate', - throttleMaxPeersNormal: 'throttle.max_peers.normal', - throttleMaxPeersSeed: 'throttle.max_peers.seed', - throttleMaxDownloads: 'throttle.max_downloads', - throttleMaxDownloadsDiv: 'throttle.max_downloads.div', - throttleMaxDownloadsGlobal: 'throttle.max_downloads.global', - throttleMaxUploads: 'throttle.max_uploads', - throttleMaxUploadsDiv: 'throttle.max_uploads.div', - throttleMaxUploadsGlobal: 'throttle.max_uploads.global', - throttleMinPeersNormal: 'throttle.min_peers.normal', - throttleMinPeersSeed: 'throttle.min_peers.seed', - trackersNumWant: 'trackers.numwant', -} as const; - -// TODO: Is this bidirectional map really necessary? -export const clientSettingsBiMap = objectUtil.reflect(clientSettingsMap); - -export type ClientSetting = keyof typeof clientSettingsMap; -export type ClientSettings = { - // TODO: Need proper types for each property - [property in ClientSetting]?: string | Record | null; -}; diff --git a/shared/types/ClientSettings.ts b/shared/types/ClientSettings.ts new file mode 100644 index 00000000..75fad875 --- /dev/null +++ b/shared/types/ClientSettings.ts @@ -0,0 +1,48 @@ +export interface DHTStats { + active: number; + buckets: number; + bytes_read: number; + bytes_written: number; + cycle: number; + errors_caught: number; + errors_received: number; + nodes: number; + peers: number; + peers_max: number; + queries_received: number; + queries_sent: number; + replies_received: number; + throttle: number | ''; + torrents: number; +} + +export interface ClientSettings { + dht: boolean; + dhtPort: number; + dhtStats: DHTStats; + directoryDefault: string; + networkHttpMaxOpen: number; + networkLocalAddress: string; + networkMaxOpenFiles: number; + networkPortOpen: boolean; + networkPortRandom: boolean; + networkPortRange: string; + piecesHashOnCompletion: boolean; + piecesMemoryMax: number; + protocolPex: boolean; + throttleGlobalDownMax: number; + throttleGlobalUpMax: number; + throttleMaxPeersNormal: number; + throttleMaxPeersSeed: number; + throttleMaxDownloads: number; + throttleMaxDownloadsDiv: number; + throttleMaxDownloadsGlobal: number; + throttleMaxUploads: number; + throttleMaxUploadsDiv: number; + throttleMaxUploadsGlobal: number; + throttleMinPeersNormal: number; + throttleMinPeersSeed: number; + trackersNumWant: number; +} + +export type ClientSetting = keyof ClientSettings; diff --git a/shared/types/api/client.ts b/shared/types/api/client.ts new file mode 100644 index 00000000..d48ca5d1 --- /dev/null +++ b/shared/types/api/client.ts @@ -0,0 +1,4 @@ +import type {ClientSettings} from '../ClientSettings'; + +// PATCH /api/client/settings +export type SetClientSettingsOptions = Partial>;