diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 4d1b57743..8098273e1 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -112,6 +112,12 @@ "leech": "Leech", "seed": "Seed" }, + "deluge": { + "download": "Download", + "upload": "Upload", + "leech": "Leech", + "seed": "Seed" + }, "sonarr": { "wanted": "Wanted", "queued": "Queued", diff --git a/src/utils/proxy/handlers/jsonrpc.js b/src/utils/proxy/handlers/jsonrpc.js new file mode 100644 index 000000000..9677fa502 --- /dev/null +++ b/src/utils/proxy/handlers/jsonrpc.js @@ -0,0 +1,82 @@ +import { JSONRPCClient, JSONRPCErrorException } from "json-rpc-2.0"; + +import { formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const logger = createLogger("jsonrpcProxyHandler"); + +export async function sendJsonRpcRequest(url, method, params, username, password) { + const headers = { + "content-type": "application/json", + "accept": "application/json" + } + + if (username && password) { + const authorization = Buffer.from(`${username}:${password}`).toString("base64"); + headers.authorization = `Basic ${authorization}`; + } + + const client = new JSONRPCClient(async (rpcRequest) => { + const httpRequestParams = { + method: "POST", + headers, + body: JSON.stringify(rpcRequest) + }; + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await httpProxy(url, httpRequestParams); + const dataString = data.toString(); + if (status === 200) { + const json = JSON.parse(dataString); + + // in order to get access to the underlying error object in the JSON response + // you must set `result` equal to undefined + if (json.error && (json.result === null)) { + json.result = undefined; + } + return client.receive(json); + } + + return Promise.reject(new Error(dataString)); + }); + + try { + const response = await client.request(method, params); + return [200, "application/json", JSON.stringify(response)]; + } + catch (e) { + if (e instanceof JSONRPCErrorException) { + return [200, "application/json", JSON.stringify({result: null, error: {code: e.code, message: e.message}})]; + } + + logger.warn("Error calling JSONPRC endpoint: %s. %s", url, e); + return [500, "application/json", JSON.stringify({result: null, error: {code: 2, message: e.toString()}})]; + } +} + +export default async function jsonrpcProxyHandler(req, res) { + const { group, service, endpoint: method } = req.query; + + if (group && service) { + const widget = await getServiceWidget(group, service); + const api = widgets?.[widget.type]?.api; + + if (!api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + if (widget) { + const url = formatApiCall(api, { ...widget }); + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await sendJsonRpcRequest(url, method, null, widget.username, widget.password); + res.status(status).end(data); + } + } + + logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); +} diff --git a/src/widgets/components.js b/src/widgets/components.js index b781172bf..e15ed4d85 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -7,6 +7,7 @@ const components = { bazarr: dynamic(() => import("./bazarr/component")), changedetectionio: dynamic(() => import("./changedetectionio/component")), coinmarketcap: dynamic(() => import("./coinmarketcap/component")), + deluge: dynamic(() => import("./deluge/component")), docker: dynamic(() => import("./docker/component")), emby: dynamic(() => import("./emby/component")), gluetun: dynamic(() => import("./gluetun/component")), diff --git a/src/widgets/deluge/component.jsx b/src/widgets/deluge/component.jsx new file mode 100644 index 000000000..40f8c672c --- /dev/null +++ b/src/widgets/deluge/component.jsx @@ -0,0 +1,52 @@ +import { useTranslation } from "next-i18next"; + +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: torrentData, error: torrentError } = useWidgetAPI(widget); + + if (torrentError) { + return ; + } + + if (!torrentData) { + return ( + + + + + + + ); + } + + const { torrents } = torrentData; + let count = 0; + let rateDl = 0; + let rateUl = 0; + let completed = 0; + for (const key of Object.keys(torrents)) { + const torrent = torrents[key]; + count += 1; + rateDl += torrent.download_payload_rate; + rateUl += torrent.upload_payload_rate; + completed += torrent.total_remaining === 0 ? 1 : 0; + } + + const leech = count - completed || 0; + + return ( + + + + + + + ); +} diff --git a/src/widgets/deluge/proxy.js b/src/widgets/deluge/proxy.js new file mode 100644 index 000000000..e9dac0d9a --- /dev/null +++ b/src/widgets/deluge/proxy.js @@ -0,0 +1,63 @@ +import { formatApiCall } from "utils/proxy/api-helpers"; +import { sendJsonRpcRequest } from "utils/proxy/handlers/jsonrpc"; +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import widgets from "widgets/widgets"; + +const logger = createLogger("delugeProxyHandler"); + +const dataMethod = "web.update_ui"; +const dataParams = [ + ["queue", "name", "total_wanted", "state", "progress", "download_payload_rate", "upload_payload_rate", "total_remaining"], + {} +]; +const loginMethod = "auth.login"; + +async function sendRpc(url, method, params, username, password) { + const [status, contentType, data] = await sendJsonRpcRequest(url, method, params, username, password); + const json = JSON.parse(data.toString()); + if (json?.error) { + if (json.error.code === 1) { + return [403, contentType, data]; + } + return [500, contentType, data]; + } + + return [status, contentType, data]; +} + +function login(url, username, password) { + return sendRpc(url, loginMethod, [password], username, password); +} + +export default async function delugeProxyHandler(req, res) { + const { group, service } = req.query; + + if (!group || !service) { + logger.debug("Invalid or missing service '%s' or group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service); + + if (!widget) { + logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group); + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const api = widgets?.[widget.type]?.api + const url = new URL(formatApiCall(api, { ...widget })); + + let [status, contentType, data] = await sendRpc(url, dataMethod, dataParams, widget.username, widget.password); + if (status === 403) { + [status, contentType, data] = await login(url, widget.username, widget.password); + if (status !== 200) { + return res.status(status).end(data); + } + + // eslint-disable-next-line no-unused-vars + [status, contentType, data] = await sendRpc(url, dataMethod, dataParams, widget.username, widget.password); + } + + return res.status(status).end(data); +} diff --git a/src/widgets/deluge/widget.js b/src/widgets/deluge/widget.js new file mode 100644 index 000000000..b5518b666 --- /dev/null +++ b/src/widgets/deluge/widget.js @@ -0,0 +1,8 @@ +import delugeProxyHandler from "./proxy"; + +const widget = { + api: "{url}/json", + proxyHandler: delugeProxyHandler, +}; + +export default widget; diff --git a/src/widgets/nzbget/proxy.js b/src/widgets/nzbget/proxy.js deleted file mode 100644 index 4feac7812..000000000 --- a/src/widgets/nzbget/proxy.js +++ /dev/null @@ -1,40 +0,0 @@ -import { JSONRPCClient } from "json-rpc-2.0"; - -import getServiceWidget from "utils/config/service-helpers"; - -export default async function nzbgetProxyHandler(req, res) { - const { group, service, endpoint } = req.query; - - if (group && service) { - const widget = await getServiceWidget(group, service); - - if (widget) { - const constructedUrl = new URL(widget.url); - constructedUrl.pathname = "jsonrpc"; - - const authorization = Buffer.from(`${widget.username}:${widget.password}`).toString("base64"); - - const client = new JSONRPCClient((jsonRPCRequest) => - fetch(constructedUrl.toString(), { - method: "POST", - headers: { - "content-type": "application/json", - authorization: `Basic ${authorization}`, - }, - body: JSON.stringify(jsonRPCRequest), - }).then(async (response) => { - if (response.status === 200) { - const jsonRPCResponse = await response.json(); - return client.receive(jsonRPCResponse); - } - - return Promise.reject(new Error(response.statusText)); - }) - ); - - return res.send(await client.request(endpoint)); - } - } - - return res.status(400).json({ error: "Invalid proxy service type" }); -} diff --git a/src/widgets/nzbget/widget.js b/src/widgets/nzbget/widget.js index 975c8dea7..841fb66c0 100644 --- a/src/widgets/nzbget/widget.js +++ b/src/widgets/nzbget/widget.js @@ -1,7 +1,8 @@ -import nzbgetProxyHandler from "./proxy"; +import jsonrpcProxyHandler from "utils/proxy/handlers/jsonrpc"; const widget = { - proxyHandler: nzbgetProxyHandler, + api: "{url}/jsonrpc", + proxyHandler: jsonrpcProxyHandler, }; export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index fe4328320..6d5c4088c 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -4,6 +4,7 @@ import autobrr from "./autobrr/widget"; import bazarr from "./bazarr/widget"; import changedetectionio from "./changedetectionio/widget"; import coinmarketcap from "./coinmarketcap/widget"; +import deluge from "./deluge/widget"; import emby from "./emby/widget"; import gluetun from "./gluetun/widget"; import gotify from "./gotify/widget"; @@ -47,6 +48,7 @@ const widgets = { bazarr, changedetectionio, coinmarketcap, + deluge, emby, gluetun, gotify,