From b3db549a6505615d11819f1a5874d143f4d2e9f9 Mon Sep 17 00:00:00 2001
From: Jason Fischer <jazzfisch@outlook.com>
Date: Mon, 12 Sep 2022 19:35:47 -0700
Subject: [PATCH] Add Transmission widget - Update http.js to support writing
 request bodies - Update http.js to support returning all response headers

resolves: #104
---
 public/locales/en/common.json                 |  6 ++
 src/components/services/widget.jsx            |  4 +-
 .../services/widgets/service/sabnzbd.jsx      |  2 +-
 .../services/widgets/service/transmission.jsx | 69 +++++++++++++++++++
 src/pages/api/services/proxy.js               |  2 +
 src/utils/api-helpers.js                      |  1 +
 src/utils/http.js                             | 12 +++-
 src/utils/proxies/transmission.js             | 56 +++++++++++++++
 8 files changed, 148 insertions(+), 4 deletions(-)
 create mode 100644 src/components/services/widgets/service/transmission.jsx
 create mode 100644 src/utils/proxies/transmission.js

diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 7c60dbe1..577e9422 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -63,6 +63,12 @@
         "upload": "Upload",
         "download": "Download"
     },
+    "transmission": {
+        "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 2bebe84a..e4a67d25 100644
--- a/src/components/services/widget.jsx
+++ b/src/components/services/widget.jsx
@@ -8,6 +8,7 @@ import Portainer from "./widgets/service/portainer";
 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 Docker from "./widgets/service/docker";
 import Pihole from "./widgets/service/pihole";
 import Rutorrent from "./widgets/service/rutorrent";
@@ -31,6 +32,8 @@ const widgetMappings = {
   emby: Emby,
   jellyfin: Jellyfin,
   nzbget: Nzbget,
+  sabnzbd: SABnzbd,
+  transmission: Transmission,
   pihole: Pihole,
   rutorrent: Rutorrent,
   speedtest: Speedtest,
@@ -41,7 +44,6 @@ const widgetMappings = {
   npm: Npm,
   tautulli: Tautulli,
   gotify: Gotify,
-  sabnzbd: SABnzbd
 };
 
 export default function Widget({ service }) {
diff --git a/src/components/services/widgets/service/sabnzbd.jsx b/src/components/services/widgets/service/sabnzbd.jsx
index d1a08a27..8c777a70 100644
--- a/src/components/services/widgets/service/sabnzbd.jsx
+++ b/src/components/services/widgets/service/sabnzbd.jsx
@@ -29,7 +29,7 @@ export default function SABnzbd({ service }) {
 
   return (
     <Widget>
-      <Block label={t("sabnzbd.rate")} value={`${queueData.queue.speed}bps`} />
+      <Block label={t("sabnzbd.rate")} value={`${queueData.queue.speed}B/s`} />
       <Block label={t("sabnzbd.queue")} value={queueData.queue.noofslots} />
       <Block label={t("sabnzbd.timeleft")} value={queueData.queue.timeleft} />
     </Widget>
diff --git a/src/components/services/widgets/service/transmission.jsx b/src/components/services/widgets/service/transmission.jsx
new file mode 100644
index 00000000..22d75288
--- /dev/null
+++ b/src/components/services/widgets/service/transmission.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 Transmission({ service }) {
+  const { t } = useTranslation();
+
+  const config = service.widget;
+
+  const { data: torrentData, error: torrentError } = useSWR(formatApiUrl(config));
+
+  if (torrentError) {
+    return <Widget error={t("widget.api_error")} />;
+  }
+
+  if (!torrentData) {
+    return (
+      <Widget>
+        <Block label={t("transmission.leech")} />
+        <Block label={t("transmission.download")} />
+        <Block label={t("transmission.seed")} />
+        <Block label={t("transmission.upload")} />
+      </Widget>
+    );
+  }
+
+  const torrents = torrentData.arguments.torrents;
+  let rateDl = 0;
+  let rateUl = 0;
+  let completed = 0;
+
+  for (let torrent of torrents) {
+    rateDl += torrent.rateDownload;
+    rateUl += torrent.rateUpload;
+    if (torrent.percentDone  === 1) {
+      completed++;
+    }
+  }
+
+  const leech = torrents.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 (
+    <Widget>
+      <Block label={t("transmission.leech")} value={leech} />
+      <Block label={t("transmission.download")} value={`${rateDl.toFixed(2)} ${unitsDl}`} />
+      <Block label={t("transmission.seed")} value={completed} />
+      <Block label={t("transmission.upload")} value={`${rateUl.toFixed(2)} ${unitsUl}`} />
+    </Widget>
+  );
+}
diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js
index 0a444029..7c64e435 100644
--- a/src/pages/api/services/proxy.js
+++ b/src/pages/api/services/proxy.js
@@ -3,6 +3,7 @@ import credentialedProxyHandler from "utils/proxies/credentialed";
 import rutorrentProxyHandler from "utils/proxies/rutorrent";
 import nzbgetProxyHandler from "utils/proxies/nzbget";
 import npmProxyHandler from "utils/proxies/npm";
+import transmissionProxyHandler from "utils/proxies/transmission";
 
 const serviceProxyHandlers = {
   // uses query param auth
@@ -27,6 +28,7 @@ const serviceProxyHandlers = {
   rutorrent: rutorrentProxyHandler,
   nzbget: nzbgetProxyHandler,
   npm: npmProxyHandler,
+  transmission: transmissionProxyHandler,
 };
 
 export default async function handler(req, res) {
diff --git a/src/utils/api-helpers.js b/src/utils/api-helpers.js
index 340ffaaa..2085fafc 100644
--- a/src/utils/api-helpers.js
+++ b/src/utils/api-helpers.js
@@ -9,6 +9,7 @@ const formats = {
   traefik: `{url}/api/{endpoint}`,
   portainer: `{url}/api/endpoints/{env}/{endpoint}`,
   rutorrent: `{url}/plugins/httprpc/action.php`,
+  transmission: `{url}/transmission/rpc`,
   jellyseerr: `{url}/api/v1/{endpoint}`,
   overseerr: `{url}/api/v1/{endpoint}`,
   ombi: `{url}/api/v1/{endpoint}`,
diff --git a/src/utils/http.js b/src/utils/http.js
index 5f32f1a1..76e882ed 100644
--- a/src/utils/http.js
+++ b/src/utils/http.js
@@ -12,7 +12,7 @@ export function httpsRequest(url, params) {
       });
 
       response.on("end", () => {
-        resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data)]);
+        resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
       });
     });
 
@@ -20,6 +20,10 @@ export function httpsRequest(url, params) {
       reject([500, error]);
     });
 
+    if (params.body) {
+      request.write(params.body);
+    }
+
     request.end();
   });
 }
@@ -34,7 +38,7 @@ export function httpRequest(url, params) {
       });
 
       response.on("end", () => {
-        resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data)]);
+        resolve([response.statusCode, response.headers["content-type"], Buffer.concat(data), response.headers]);
       });
     });
 
@@ -42,6 +46,10 @@ export function httpRequest(url, params) {
       reject([500, error]);
     });
 
+    if (params.body) {
+      request.write(params.body);
+    }
+
     request.end();
   });
 }
diff --git a/src/utils/proxies/transmission.js b/src/utils/proxies/transmission.js
new file mode 100644
index 00000000..250b235c
--- /dev/null
+++ b/src/utils/proxies/transmission.js
@@ -0,0 +1,56 @@
+import { httpProxy } from "utils/http";
+import { formatApiCall } from "utils/api-helpers";
+
+import getServiceWidget from "utils/service-helpers";
+
+export default async function transmissionProxyHandler(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 csrfHeaderName = "x-transmission-session-id";
+
+  const method = "POST";
+  const body = JSON.stringify({
+    method: "torrent-get",
+    arguments: {
+      fields: ["percentDone", "status", "rateDownload", "rateUpload"]
+    }
+  });
+
+  const reqHeaders = {
+    "content-type": "application/json",
+  };
+
+  let [status, contentType, data, responseHeaders] = await httpProxy(url, {
+    method: method,
+    auth: `${widget.username}:${widget.password}`,
+    body: body,
+    headers: reqHeaders,
+  });
+
+  if (status === 409) {
+    // Transmission is rejecting the request, but returning a CSRF token
+    reqHeaders[csrfHeaderName] = responseHeaders[csrfHeaderName];
+
+    // retry the request, now with the CSRF token
+    [status, contentType, data] = await httpProxy(url, {
+      method: method,
+      auth: `${widget.username}:${widget.password}`,
+      body: body,
+      headers: reqHeaders,
+    });
+  }
+
+  if (contentType) res.setHeader("Content-Type", contentType);
+  return res.status(status).send(data);
+}