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