From 6da1e98c830c8a4c6898ace68a4483a93e8cc1d5 Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Fri, 16 Sep 2022 19:11:57 -0700 Subject: [PATCH] Add qBittorrent Widget - extract cookie jar functionality into its own file - use i18n for more strings in existing widgets completes: #152 associated: #123 --- public/locales/en/common.json | 6 ++ src/components/services/widget.jsx | 2 + .../services/widgets/service/bazarr.jsx | 4 +- .../services/widgets/service/jackett.jsx | 4 +- .../services/widgets/service/lidarr.jsx | 6 +- .../services/widgets/service/qbittorrent.jsx | 69 +++++++++++++++++++ .../services/widgets/service/readarr.jsx | 6 +- .../services/widgets/service/sabnzbd.jsx | 2 +- .../services/widgets/service/transmission.jsx | 4 +- src/pages/api/services/proxy.js | 2 + src/utils/api-helpers.js | 1 + src/utils/cookie-jar.js | 34 +++++++++ src/utils/http.js | 30 ++------ src/utils/proxies/qbittorrent.js | 58 ++++++++++++++++ 14 files changed, 189 insertions(+), 39 deletions(-) create mode 100644 src/components/services/widgets/service/qbittorrent.jsx create mode 100644 src/utils/cookie-jar.js create mode 100644 src/utils/proxies/qbittorrent.js diff --git a/public/locales/en/common.json b/public/locales/en/common.json index de399a745..26f2e432a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -70,6 +70,12 @@ "leech": "Leech", "seed": "Seed" }, + "qbittorrent": { + "download": "Download", + "upload": "Upload", + "leech": "Leech", + "seed": "Seed" + }, "sonarr": { "wanted": "Wanted", "queued": "Queued", diff --git a/src/components/services/widget.jsx b/src/components/services/widget.jsx index c0f6113cc..01dec3066 100644 --- a/src/components/services/widget.jsx +++ b/src/components/services/widget.jsx @@ -11,6 +11,7 @@ import Emby from "./widgets/service/emby"; import Nzbget from "./widgets/service/nzbget"; import SABnzbd from "./widgets/service/sabnzbd"; import Transmission from "./widgets/service/transmission"; +import QBittorrent from "./widgets/service/qbittorrent"; import Docker from "./widgets/service/docker"; import Pihole from "./widgets/service/pihole"; import Rutorrent from "./widgets/service/rutorrent"; @@ -41,6 +42,7 @@ const widgetMappings = { nzbget: Nzbget, sabnzbd: SABnzbd, transmission: Transmission, + qbittorrent: QBittorrent, pihole: Pihole, rutorrent: Rutorrent, speedtest: Speedtest, diff --git a/src/components/services/widgets/service/bazarr.jsx b/src/components/services/widgets/service/bazarr.jsx index 030af3f40..8d47a4439 100644 --- a/src/components/services/widgets/service/bazarr.jsx +++ b/src/components/services/widgets/service/bazarr.jsx @@ -29,8 +29,8 @@ export default function Bazarr({ service }) { return ( - - + + ); } diff --git a/src/components/services/widgets/service/jackett.jsx b/src/components/services/widgets/service/jackett.jsx index c6583711d..b531f42c1 100644 --- a/src/components/services/widgets/service/jackett.jsx +++ b/src/components/services/widgets/service/jackett.jsx @@ -30,8 +30,8 @@ export default function Jackett({ service }) { return ( - - + + ); } diff --git a/src/components/services/widgets/service/lidarr.jsx b/src/components/services/widgets/service/lidarr.jsx index 2a57844ac..17689a572 100644 --- a/src/components/services/widgets/service/lidarr.jsx +++ b/src/components/services/widgets/service/lidarr.jsx @@ -33,9 +33,9 @@ export default function Lidarr({ service }) { return ( - - - + + + ); } diff --git a/src/components/services/widgets/service/qbittorrent.jsx b/src/components/services/widgets/service/qbittorrent.jsx new file mode 100644 index 000000000..43999947f --- /dev/null +++ b/src/components/services/widgets/service/qbittorrent.jsx @@ -0,0 +1,69 @@ +import useSWR from "swr"; +import { useTranslation } from "react-i18next"; + +import Widget from "../widget"; +import Block from "../block"; + +import { formatApiUrl } from "utils/api-helpers"; + +export default function QBittorrent ({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: torrentData, error: torrentError } = useSWR(formatApiUrl(config, "torrents/info")); + + if (torrentError) { + return ; + } + + if (!torrentData) { + return ( + + + + + + + ); + } + + let rateDl = 0; + let rateUl = 0; + let completed = 0; + + for (let i = 0; i < torrentData.length; i += 1) { + const torrent = torrentData[i]; + rateDl += torrent.dlspeed; + rateUl += torrent.upspeed; + if (torrent.progress === 1) { + completed += 1; + } + } + + const leech = torrentData.length - completed; + + let unitsDl = "KB/s"; + let unitsUl = "KB/s"; + rateDl /= 1024; + rateUl /= 1024; + + if (rateDl > 1024) { + rateDl /= 1024; + unitsDl = "MB/s"; + } + + if (rateUl > 1024) { + rateUl /= 1024; + unitsUl = "MB/s"; + } + + return ( + + + + + + + ); +} diff --git a/src/components/services/widgets/service/readarr.jsx b/src/components/services/widgets/service/readarr.jsx index 5aa0cba4d..2117cde8f 100644 --- a/src/components/services/widgets/service/readarr.jsx +++ b/src/components/services/widgets/service/readarr.jsx @@ -33,9 +33,9 @@ export default function Readarr({ service }) { return ( - - - + + + ); } diff --git a/src/components/services/widgets/service/sabnzbd.jsx b/src/components/services/widgets/service/sabnzbd.jsx index 8c777a70a..b38747adb 100644 --- a/src/components/services/widgets/service/sabnzbd.jsx +++ b/src/components/services/widgets/service/sabnzbd.jsx @@ -30,7 +30,7 @@ export default function SABnzbd({ service }) { return ( - + ); diff --git a/src/components/services/widgets/service/transmission.jsx b/src/components/services/widgets/service/transmission.jsx index f8f1f25d4..893bcc453 100644 --- a/src/components/services/widgets/service/transmission.jsx +++ b/src/components/services/widgets/service/transmission.jsx @@ -61,9 +61,9 @@ export default function Transmission({ service }) { return ( - + - + ); diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index 5cb1f678c..38e9f6b23 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -4,6 +4,7 @@ import rutorrentProxyHandler from "utils/proxies/rutorrent"; import nzbgetProxyHandler from "utils/proxies/nzbget"; import npmProxyHandler from "utils/proxies/npm"; import transmissionProxyHandler from "utils/proxies/transmission"; +import qbittorrentProxyHandler from "utils/proxies/qbittorrent"; const serviceProxyHandlers = { // uses query param auth @@ -34,6 +35,7 @@ const serviceProxyHandlers = { nzbget: nzbgetProxyHandler, npm: npmProxyHandler, transmission: transmissionProxyHandler, + qbittorrent: qbittorrentProxyHandler, }; export default async function handler(req, res) { diff --git a/src/utils/api-helpers.js b/src/utils/api-helpers.js index cb284c671..be24f8a28 100644 --- a/src/utils/api-helpers.js +++ b/src/utils/api-helpers.js @@ -10,6 +10,7 @@ const formats = { portainer: `{url}/api/endpoints/{env}/{endpoint}`, rutorrent: `{url}/plugins/httprpc/action.php`, transmission: `{url}/transmission/rpc`, + qbittorrent: `{url}/api/v2/{endpoint}`, jellyseerr: `{url}/api/v1/{endpoint}`, overseerr: `{url}/api/v1/{endpoint}`, ombi: `{url}/api/v1/{endpoint}`, diff --git a/src/utils/cookie-jar.js b/src/utils/cookie-jar.js new file mode 100644 index 000000000..0d5b33677 --- /dev/null +++ b/src/utils/cookie-jar.js @@ -0,0 +1,34 @@ +/* eslint-disable no-param-reassign */ +import { Cookie, CookieJar } from 'tough-cookie'; + +const cookieJar = new CookieJar(); + +export function setCookieHeader(url, params) { + // add cookie header, if we have one in the jar + const existingCookie = cookieJar.getCookieStringSync(url.toString()); + if (existingCookie) { + params.headers = params.headers ?? {}; + params.headers.Cookie = existingCookie; + } +} + +export function addCookieToJar(url, headers) { + let cookieHeader = headers['set-cookie']; + if (headers instanceof Headers) { + cookieHeader = headers.get('set-cookie'); + } + + if (!cookieHeader || cookieHeader.length === 0) return; + + let cookies = null; + if (cookieHeader instanceof Array) { + cookies = cookieHeader.map(Cookie.parse); + } + else { + cookies = [Cookie.parse(cookieHeader)]; + } + + for (let i = 0; i < cookies.length; i += 1) { + cookieJar.setCookieSync(cookies[i], url.toString()); + } +} diff --git a/src/utils/http.js b/src/utils/http.js index 9f663cd43..bfeec046f 100644 --- a/src/utils/http.js +++ b/src/utils/http.js @@ -1,39 +1,15 @@ /* eslint-disable prefer-promise-reject-errors */ /* eslint-disable no-param-reassign */ import { http, https } from "follow-redirects"; -import { Cookie, CookieJar } from 'tough-cookie'; -const cookieJar = new CookieJar(); - -function setCookieHeader(url, params) { - // add cookie header, if we have one in the jar - const existingCookie = cookieJar.getCookieStringSync(url.toString()); - if (existingCookie) { - params.headers = params.headers ?? {}; - params.headers.Cookie = existingCookie; - } -} +import { addCookieToJar, setCookieHeader } from "utils/cookie-jar"; function addCookieHandler(url, params) { setCookieHeader(url, params); // handle cookies during redirects params.beforeRedirect = (options, responseInfo) => { - const cookieHeader = responseInfo.headers['set-cookie']; - if (!cookieHeader || cookieHeader.length === 0) return; - - let cookies = null; - if (cookieHeader instanceof Array) { - cookies = cookieHeader.map(Cookie.parse); - } - else { - cookies = [Cookie.parse(cookieHeader)]; - } - - for (let i = 0; i < cookies.length; i += 1) { - cookieJar.setCookieSync(cookies[i], options.href); - } - + addCookieToJar(options.href, responseInfo.headers); setCookieHeader(options.href, options); }; } @@ -49,6 +25,7 @@ export function httpsRequest(url, params) { }); response.on("end", () => { + addCookieToJar(url, response.headers); resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]); }); }); @@ -76,6 +53,7 @@ export function httpRequest(url, params) { }); response.on("end", () => { + addCookieToJar(url, response.headers); resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]); }); }); diff --git a/src/utils/proxies/qbittorrent.js b/src/utils/proxies/qbittorrent.js new file mode 100644 index 000000000..df3410002 --- /dev/null +++ b/src/utils/proxies/qbittorrent.js @@ -0,0 +1,58 @@ +import { formatApiCall } from "utils/api-helpers"; +import { addCookieToJar, setCookieHeader } from "utils/cookie-jar"; +import { httpProxy } from "utils/http"; +import getServiceWidget from "utils/service-helpers"; + +async function login(widget, params) { + const loginUrl = new URL(`${widget.url}/api/v2/auth/login`); + 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, { + 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])); +} + +export default async function qbittorrentProxyHandler(req, res) { + const { group, service, endpoint } = req.query; + + if (!group || !service) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const widget = await getServiceWidget(group, service); + + if (!widget) { + return res.status(400).json({ error: "Invalid proxy service type" }); + } + + const url = new URL(formatApiCall(widget.type, { endpoint, ...widget })); + const params = { method: "GET", headers: {} }; + setCookieHeader(url, params); + + if (!params.headers.Cookie) { + const [status, data] = await login(widget, params); + + if (status !== 200) { + return res.status(status).end(data); + } + if (data.toString() !== 'Ok.') { + return res.status(401).end(data); + } + } + + const [status, contentType, data] = await httpProxy(url, params); + + if (contentType) res.setHeader("Content-Type", contentType); + return res.status(status).send(data); +} \ No newline at end of file