diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index c90287da6..437088bd3 100755
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -653,5 +653,10 @@
"whatsupdocker": {
"monitoring": "Monitoring",
"updates": "Updates"
+ },
+ "jdownloader": {
+ "downloadCount": "Queue Count",
+ "downloadQueueSize": "Queue Size",
+ "downloadSpeed": "Download Speed"
}
}
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 589a93ad2..97760d368 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -31,6 +31,7 @@ const components = {
healthchecks: dynamic(() => import("./healthchecks/component")),
immich: dynamic(() => import("./immich/component")),
jackett: dynamic(() => import("./jackett/component")),
+ jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./emby/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
komga: dynamic(() => import("./komga/component")),
@@ -91,4 +92,4 @@ const components = {
xteve: dynamic(() => import("./xteve/component")),
};
-export default components;
+export default components;
\ No newline at end of file
diff --git a/src/widgets/jdownloader/component.jsx b/src/widgets/jdownloader/component.jsx
new file mode 100644
index 000000000..d8fea9caf
--- /dev/null
+++ b/src/widgets/jdownloader/component.jsx
@@ -0,0 +1,37 @@
+import { useTranslation } from "next-i18next";
+
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+
+ const { data: jdownloaderData, error: jdownloaderAPIError } = useWidgetAPI(widget, "unified", {
+ refreshInterval: 30000,
+ });
+
+ if (jdownloaderAPIError) {
+ return ;
+ }
+
+ if (!jdownloaderData) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/widgets/jdownloader/proxy.js b/src/widgets/jdownloader/proxy.js
new file mode 100644
index 000000000..353192ac7
--- /dev/null
+++ b/src/widgets/jdownloader/proxy.js
@@ -0,0 +1,191 @@
+/* eslint-disable no-underscore-dangle */
+import crypto from 'crypto';
+import querystring from 'querystring';
+
+import { sha256, uniqueRid, validateRid, createEncryptionToken, decrypt, encrypt } from "./tools"
+
+import getServiceWidget from "utils/config/service-helpers";
+import { httpProxy } from "utils/proxy/http";
+import createLogger from "utils/logger";
+
+const proxyName = "jdownloaderProxyHandler";
+const logger = createLogger(proxyName);
+
+async function getWidget(req) {
+ const { group, service } = req.query;
+ if (!group || !service) {
+ logger.debug("Invalid or missing service '%s' or group '%s'", service, group);
+ return null;
+ }
+ const widget = await getServiceWidget(group, service);
+ if (!widget) {
+ logger.debug("Invalid or missing widget for service '%s' in group '%s'", service, group);
+ return null;
+ }
+
+ return widget;
+}
+
+async function login(loginSecret, deviceSecret, params) {
+ const rid = uniqueRid();
+ const path = `/my/connect?${querystring.stringify({...params, rid})}`;
+
+ const signature = crypto
+ .createHmac('sha256', loginSecret)
+ .update(path)
+ .digest('hex');
+ const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`
+
+ const [status, contentType, data] = await httpProxy(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (status !== 200) {
+ logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
+ return [status, data];
+ }
+
+ try {
+ const decryptedData = JSON.parse(decrypt(data.toString(), loginSecret))
+ const sessionToken = decryptedData.sessiontoken;
+ validateRid(decryptedData, rid);
+ const serverEncryptionToken = createEncryptionToken(loginSecret, sessionToken);
+ const deviceEncryptionToken = createEncryptionToken(deviceSecret, sessionToken);
+ return [status, decryptedData, contentType, serverEncryptionToken, deviceEncryptionToken, sessionToken];
+ } catch (e) {
+ logger.error("Error decoding jdownloader API data. Data: %s", data.toString());
+ return [status, null];
+ }
+}
+
+
+async function getDevice(serverEncryptionToken, deviceName, params) {
+ const rid = uniqueRid();
+ const path = `/my/listdevices?${querystring.stringify({...params, rid})}`;
+ const signature = crypto
+ .createHmac('sha256', serverEncryptionToken)
+ .update(path)
+ .digest('hex');
+ const url = `${new URL(`https://api.jdownloader.org${path}&signature=${signature}`)}`
+
+ const [status, , data] = await httpProxy(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ if (status !== 200) {
+ logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
+ return [status, data];
+ }
+
+ try {
+ const decryptedData = JSON.parse(decrypt(data.toString(), serverEncryptionToken))
+ const filteredDevice = decryptedData.list.filter(device => device.name === deviceName);
+ return [status, filteredDevice[0].id];
+ } catch (e) {
+ logger.error("Error decoding jdownloader API data. Data: %s", data.toString());
+ return [status, null];
+ }
+
+}
+
+function createBody(rid, query, params) {
+ const baseBody = {
+ apiVer: 1,
+ rid,
+ url: query
+ };
+ return params ? {...baseBody, params: [JSON.stringify(params)] } : baseBody;
+}
+
+async function queryPackages(deviceEncryptionToken, deviceId, sessionToken, params) {
+ const rid = uniqueRid();
+ const body = encrypt(JSON.stringify(createBody(rid, '/downloadsV2/queryPackages', params)), deviceEncryptionToken);
+ const url = `${new URL(`https://api.jdownloader.org/t_${encodeURI(sessionToken)}_${encodeURI(deviceId)}/downloadsV2/queryPackages`)}`
+ const [status, , data] = await httpProxy(url, {
+ method: 'POST',
+ body,
+ });
+
+ if (status !== 200) {
+ logger.error("HTTP %d communicating with jdownloader. Data: %s", status, data.toString());
+ return [status, data];
+ }
+
+ try {
+ const decryptedData = JSON.parse(decrypt(data.toString(), deviceEncryptionToken))
+ return decryptedData.data;
+ } catch (e) {
+ logger.error("Error decoding JDRss jdownloader data. Data: %s", data.toString());
+ return [status, null];
+ }
+
+}
+
+
+export default async function jdownloaderProxyHandler(req, res) {
+ const widget = await getWidget(req);
+
+ if (!widget) {
+ return res.status(400).json({ error: "Invalid proxy service type" });
+ }
+ logger.debug("Getting data from JDRss API");
+ const {username} = widget
+ const {password} = widget
+
+ const appKey = "homepage"
+ const loginSecret = sha256(`${username}${password}server`)
+ const deviceSecret = sha256(`${username}${password}device`)
+ const email = username;
+
+ const loginData = await login(loginSecret, deviceSecret, {
+ appKey,
+ email
+ })
+
+ const deviceData = await getDevice(loginData[3], widget.client, {
+ sessiontoken: loginData[5]
+ })
+
+ const packageStatus = await queryPackages(loginData[4], deviceData[1], loginData[5], {
+ "bytesLoaded": false,
+ "bytesTotal": true,
+ "comment": false,
+ "enabled": true,
+ "eta": false,
+ "priority": false,
+ "finished": true,
+ "running": true,
+ "speed": true,
+ "status": true,
+ "childCount": false,
+ "hosts": false,
+ "saveTo": false,
+ "maxResults": -1,
+ "startAt": 0,
+ }
+ )
+
+ let totalBytes = 0;
+ let totalSpeed = 0;
+ packageStatus.forEach(file => {
+ totalBytes += file.bytesTotal;
+ if (file.speed) {
+ totalSpeed += file.speed;
+ }
+ });
+
+ const data = {
+ downloadCount: packageStatus.length,
+ totalBytes,
+ totalSpeed
+ };
+
+ return res.send(data);
+
+}
\ No newline at end of file
diff --git a/src/widgets/jdownloader/tools.js b/src/widgets/jdownloader/tools.js
new file mode 100644
index 000000000..d678b0725
--- /dev/null
+++ b/src/widgets/jdownloader/tools.js
@@ -0,0 +1,55 @@
+import crypto from 'crypto';
+
+export function sha256(data) {
+ return crypto
+ .createHash('sha256')
+ .update(data)
+ .digest();
+}
+
+export function uniqueRid() {
+ return Math.floor(Math.random() * 10e12);
+}
+
+export function validateRid(decryptedData, rid) {
+ if (decryptedData.rid !== rid) {
+ throw new Error('RequestID mismatch');
+ }
+ return decryptedData;
+
+}
+
+export function decrypt(data, ivKey) {
+ const iv = ivKey.slice(0, ivKey.length / 2);
+ const key = ivKey.slice(ivKey.length / 2, ivKey.length);
+ const cipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
+ return Buffer.concat([
+ cipher.update(data, 'base64'),
+ cipher.final()
+ ]).toString();
+}
+
+export function createEncryptionToken(oldTokenBuff, updateToken) {
+ const updateTokenBuff = Buffer.from(updateToken, 'hex');
+ const mergedBuffer = Buffer.concat([oldTokenBuff, updateTokenBuff], oldTokenBuff.length + updateTokenBuff.length);
+ return sha256(mergedBuffer);
+}
+
+export function encrypt(data, ivKey) {
+ if (typeof data !== 'string') {
+ throw new Error('data no es un string');
+ }
+ if (!(ivKey instanceof Buffer)) {
+ throw new Error('ivKey no es un buffer');
+ }
+ if (ivKey.length !== 32) {
+ throw new Error('ivKey tiene que tener tamaƱo 32');
+ }
+ const stringIVKey = ivKey.toString('hex');
+ const stringIV = stringIVKey.substring(0, stringIVKey.length / 2);
+ const stringKey = stringIVKey.substring(stringIVKey.length / 2, stringIVKey.length);
+ const iv = Buffer.from(stringIV, 'hex');
+ const key = Buffer.from(stringKey, 'hex');
+ const cipher = crypto.createCipheriv('aes-128-cbc', key, iv);
+ return cipher.update(data, 'utf8', 'base64') + cipher.final('base64');
+}
\ No newline at end of file
diff --git a/src/widgets/jdownloader/widget.js b/src/widgets/jdownloader/widget.js
new file mode 100644
index 000000000..acd25d744
--- /dev/null
+++ b/src/widgets/jdownloader/widget.js
@@ -0,0 +1,15 @@
+import jdownloaderProxyHandler from "./proxy";
+
+const widget = {
+ api: "https://api.jdownloader.org/{endpoint}/&signature={signature}",
+ proxyHandler: jdownloaderProxyHandler,
+
+ mappings: {
+ unified: {
+ endpoint: "/",
+ signature: "",
+ },
+ },
+};
+
+export default widget;
\ No newline at end of file
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index f843a1686..86c4266fc 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -27,6 +27,7 @@ import healthchecks from "./healthchecks/widget";
import immich from "./immich/widget";
import jackett from "./jackett/widget";
import jellyseerr from "./jellyseerr/widget";
+import jdownloader from "./jdownloader/widget";
import komga from "./komga/widget";
import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget";
@@ -113,6 +114,7 @@ const widgets = {
healthchecks,
immich,
jackett,
+ jdownloader,
jellyfin: emby,
jellyseerr,
komga,