From 38605915c65e728bfb6c71c24b56d8804f297370 Mon Sep 17 00:00:00 2001 From: Jesse Chan Date: Wed, 3 Mar 2021 12:47:13 +0800 Subject: [PATCH] server: rTorrent: use JSON-RPC when available --- .../services/rTorrent/clientGatewayService.ts | 23 +++++-- .../services/rTorrent/clientRequestManager.ts | 9 ++- server/services/rTorrent/util/scgiUtil.ts | 67 ++++++++++++++++++- 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/server/services/rTorrent/clientGatewayService.ts b/server/services/rTorrent/clientGatewayService.ts index 165a9baa..c6e1d4e3 100644 --- a/server/services/rTorrent/clientGatewayService.ts +++ b/server/services/rTorrent/clientGatewayService.ts @@ -804,14 +804,29 @@ class RTorrentClientGatewayService extends ClientGatewayService { torrentTracker: string[]; transferSummary: string[]; }> { - const methodList: Array = await this.clientRequestManager - .methodCall('system.listMethods', []) - .then(this.processClientRequestSuccess, this.processRTorrentRequestError) - .catch((e) => { + let methodList: Array = []; + const listMethods = () => { + return this.clientRequestManager + .methodCall('system.listMethods', []) + .then(this.processClientRequestSuccess, this.processRTorrentRequestError); + }; + + this.clientRequestManager.isJSONCapable = true; + methodList = await listMethods().catch((e: RPCError) => { + if (e.isRPCError || e.name == 'SyntaxError') { + this.clientRequestManager.isJSONCapable = false; + } else if (!fallback) { + throw e; + } + }); + + if (!this.clientRequestManager.isJSONCapable) { + methodList = await listMethods().catch((e) => { if (!fallback) { throw e; } }); + } const getAvailableMethodCalls = methodList?.length > 0 diff --git a/server/services/rTorrent/clientRequestManager.ts b/server/services/rTorrent/clientRequestManager.ts index c2ba1b6c..e934460c 100644 --- a/server/services/rTorrent/clientRequestManager.ts +++ b/server/services/rTorrent/clientRequestManager.ts @@ -2,8 +2,8 @@ import type {NetConnectOpts} from 'net'; import type {RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings'; +import {methodCallJSON, methodCallXML} from './util/scgiUtil'; import {sanitizePath} from '../../util/fileUtil'; -import scgiUtil from './util/scgiUtil'; import type {MultiMethodCalls} from './util/rTorrentMethodCallUtil'; @@ -11,6 +11,7 @@ type MethodCallParameters = Array; class ClientRequestManager { connectionSettings: RTorrentConnectionSettings; + isJSONCapable = false; isRequestPending = false; lastResponseTimestamp = 0; pendingRequests: Array<{ @@ -70,7 +71,11 @@ class ClientRequestManager { port: this.connectionSettings.port, }; - return scgiUtil.methodCall(connectionOptions, methodName, parameters).then( + const methodCall = this.isJSONCapable + ? methodCallJSON(connectionOptions, methodName, parameters) + : methodCallXML(connectionOptions, methodName, parameters); + + return methodCall.then( (response) => { this.handleRequestEnd(); return response; diff --git a/server/services/rTorrent/util/scgiUtil.ts b/server/services/rTorrent/util/scgiUtil.ts index 8d7aa0e5..961decf6 100644 --- a/server/services/rTorrent/util/scgiUtil.ts +++ b/server/services/rTorrent/util/scgiUtil.ts @@ -1,6 +1,11 @@ import net from 'net'; + import deserializer from './XMLRPCDeserializer'; -import serializer, {XMLRPCValue} from './XMLRPCSerializer'; +import serializer from './XMLRPCSerializer'; +import {RPCError} from '../types/RPCError'; + +import type {MultiMethodCalls} from './rTorrentMethodCallUtil'; +import type {XMLRPCValue} from './XMLRPCSerializer'; const NULL_CHAR = String.fromCharCode(0); @@ -13,7 +18,7 @@ const bufferStream = (stream: net.Socket): Promise => { }); }; -const methodCall = (options: net.NetConnectOpts, methodName: string, params: XMLRPCValue[]) => +export const methodCallXML = (options: net.NetConnectOpts, methodName: string, params: XMLRPCValue[]) => // TODO: better typings // eslint-disable-next-line @typescript-eslint/no-explicit-any new Promise((resolve, reject) => { @@ -39,4 +44,60 @@ const methodCall = (options: net.NetConnectOpts, methodName: string, params: XML .then(resolve, reject); }); -export default {methodCall}; +export const methodCallJSON = (options: net.NetConnectOpts, methodName: string, params: unknown[]) => + // TODO: better typings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new Promise((resolve, reject) => { + const stream = net.connect(options); + const request = + methodName == 'system.multicall' + ? (params[0] as MultiMethodCalls).map((call) => ({ + jsonrpc: '2.0', + id: null, + method: call.methodName, + params: call.params, + })) + : { + jsonrpc: '2.0', + id: null, + method: methodName, + params, + }; + + const json = JSON.stringify(request); + const jsonLength = Buffer.byteLength(json, 'utf8'); + + stream.on('error', reject); + stream.setEncoding('UTF8'); + + const headerItems = [ + `CONTENT_LENGTH${NULL_CHAR}${jsonLength}${NULL_CHAR}`, + `CONTENT_TYPE${NULL_CHAR}application/json${NULL_CHAR}`, + `SCGI${NULL_CHAR}1${NULL_CHAR}`, + ]; + + const headerLength = headerItems.reduce((accumulator, headerItem) => accumulator + headerItem.length, 0); + + stream.end(`${headerLength}:${headerItems.join('')},${json}`); + + bufferStream(stream) + .then((data: string) => { + const jsonResponse = JSON.parse(data.slice(data.lastIndexOf('\n'))); + if (Array.isArray(jsonResponse)) { + return jsonResponse.map((response) => { + if (response.result == null) { + const {code, message} = response.error || {}; + throw RPCError(code, message); + } + return response.result; + }); + } else { + if (jsonResponse.result == null) { + const {code, message} = jsonResponse.error || {}; + throw RPCError(code, message); + } + return jsonResponse.result; + } + }) + .then(resolve, reject); + });