diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 063e33b55..2ab724a85 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..274276122 --- /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) { + headers.authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; + } + + const client = new JSONRPCClient(async (rpcRequest) => { + const body = JSON.stringify(rpcRequest); + const httpRequestParams = { + method: "POST", + headers, + body + }; + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await httpProxy(url, httpRequestParams); + if (status === 200) { + const json = JSON.parse(data.toString()); + + // 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(data?.error ? data : new Error(data.toString())); + }); + + try { + const response = await client.request(method, params); + return [200, "application/json", JSON.stringify(response)]; + } + catch (e) { + if (e instanceof JSONRPCErrorException) { + logger.debug("Error calling JSONPRC endpoint: %s. %s", url, e.message); + 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); + return 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/utils/proxy/http.js b/src/utils/proxy/http.js index 16b58bf70..e07f06ff8 100644 --- a/src/utils/proxy/http.js +++ b/src/utils/proxy/http.js @@ -18,10 +18,15 @@ function addCookieHandler(url, params) { }; } -export function httpsRequest(url, params) { +function handleRequest(requestor, url, params) { return new Promise((resolve, reject) => { addCookieHandler(url, params); - const request = https.request(url, params, (response) => { + if (params?.body) { + params.headers = params.headers ?? {}; + params.headers['content-length'] = Buffer.byteLength(params.body); + } + + const request = requestor.request(url, params, (response) => { const data = []; response.on("data", (chunk) => { @@ -38,7 +43,7 @@ export function httpsRequest(url, params) { reject([500, error]); }); - if (params.body) { + if (params?.body) { request.write(params.body); } @@ -46,32 +51,12 @@ export function httpsRequest(url, params) { }); } -export function httpRequest(url, params) { - return new Promise((resolve, reject) => { - addCookieHandler(url, params); - const request = http.request(url, params, (response) => { - const data = []; - - response.on("data", (chunk) => { - data.push(chunk); - }); - - response.on("end", () => { - addCookieToJar(url, response.headers); - resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]); - }); - }); - - request.on("error", (error) => { - reject([500, error]); - }); - - if (params.body) { - request.write(params.body); - } +export function httpsRequest(url, params) { + return handleRequest(https, url, params); +} - request.end(); - }); +export function httpRequest(url, params) { + return handleRequest(http, url, params); } export async function httpProxy(url, params = {}) { diff --git a/src/widgets/components.js b/src/widgets/components.js index eb7c8127e..68f114765 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..6615cac0c --- /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; + const keys = torrents ? Object.keys(torrents) : []; + + let rateDl = 0; + let rateUl = 0; + let completed = 0; + for (let i = 0; i < keys.length; i += 1) { + const torrent = torrents[keys[i]]; + rateDl += torrent.download_payload_rate; + rateUl += torrent.upload_payload_rate; + completed += torrent.total_remaining === 0 ? 1 : 0; + } + + const leech = keys.length - 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/qbittorrent/proxy.js b/src/widgets/qbittorrent/proxy.js index 14271b656..e1ea7f901 100644 --- a/src/widgets/qbittorrent/proxy.js +++ b/src/widgets/qbittorrent/proxy.js @@ -1,30 +1,23 @@ import { formatApiCall } from "utils/proxy/api-helpers"; -import { addCookieToJar, setCookieHeader } from "utils/proxy/cookie-jar"; import { httpProxy } from "utils/proxy/http"; import getServiceWidget from "utils/config/service-helpers"; import createLogger from "utils/logger"; const logger = createLogger("qbittorrentProxyHandler"); -async function login(widget, params) { +async function login(widget) { logger.debug("qBittorrent is rejecting the request, logging in."); const loginUrl = new URL(`${widget.url}/api/v2/auth/login`).toString(); const loginBody = `username=${encodeURI(widget.username)}&password=${encodeURI(widget.password)}`; - - // using fetch intentionally, for login only, as the httpProxy method causes qBittorrent to - // complain about header encoding - return fetch(loginUrl, { + const loginParams = { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: loginBody, - }) - .then(async (response) => { - addCookieToJar(loginUrl, response.headers); - setCookieHeader(loginUrl, params); - const data = await response.text(); - return [response.status, data]; - }) - .catch((err) => [500, err]); + } + + // eslint-disable-next-line no-unused-vars + const [status, contentType, data] = await httpProxy(loginUrl, loginParams); + return [status, data]; } export default async function qbittorrentProxyHandler(req, res) { @@ -44,11 +37,10 @@ export default async function qbittorrentProxyHandler(req, res) { const url = new URL(formatApiCall("{url}/api/v2/{endpoint}", { endpoint, ...widget })); const params = { method: "GET", headers: {} }; - setCookieHeader(url, params); let [status, contentType, data] = await httpProxy(url, params); if (status === 403) { - [status, data] = await login(widget, params); + [status, data] = await login(widget); if (status !== 200) { logger.error("HTTP %d logging in to qBittorrent. Data: %s", status, data); @@ -59,9 +51,9 @@ export default async function qbittorrentProxyHandler(req, res) { logger.error("Error logging in to qBittorrent: Data: %s", data); return res.status(401).end(data); } - } - [status, contentType, data] = await httpProxy(url, params); + [status, contentType, data] = await httpProxy(url, params); + } if (status !== 200) { logger.error("HTTP %d getting data from qBittorrent. Data: %s", status, data); diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 2d2f453d4..72417b77a 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"; @@ -49,6 +50,7 @@ const widgets = { bazarr, changedetectionio, coinmarketcap, + deluge, emby, gluetun, gotify,