diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index de399a74..26f2e432 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 c0f6113c..01dec306 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 030af3f4..8d47a443 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 c6583711..b531f42c 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 2a57844a..17689a57 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 00000000..43999947
--- /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 5aa0cba4..2117cde8 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 8c777a70..b38747ad 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 f8f1f25d..893bcc45 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 5cb1f678..38e9f6b2 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 cb284c67..be24f8a2 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 00000000..0d5b3367
--- /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 9f663cd4..bfeec046 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 00000000..df341000
--- /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