From ad10b4820474d9bf70ddcef1fcaf14418f0eeba3 Mon Sep 17 00:00:00 2001
From: Karl Hudgell <karl.hudgell@bjss.com>
Date: Tue, 23 May 2023 12:07:49 +0100
Subject: [PATCH] NextPVR Service Widget

---
 package.json                      |   3 +-
 pnpm-lock.yaml                    |   7 ++
 public/locales/en/common.json     |   8 +-
 src/widgets/components.js         |   1 +
 src/widgets/nextpvr/component.jsx |  35 +++++++
 src/widgets/nextpvr/proxy.js      | 153 ++++++++++++++++++++++++++++++
 src/widgets/nextpvr/widget.js     |  14 +++
 src/widgets/widgets.js            |   2 +
 8 files changed, 220 insertions(+), 3 deletions(-)
 create mode 100644 src/widgets/nextpvr/component.jsx
 create mode 100644 src/widgets/nextpvr/proxy.js
 create mode 100644 src/widgets/nextpvr/widget.js

diff --git a/package.json b/package.json
index b46cea8d..f4f46180 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
     "react-dom": "^18.2.0",
     "react-i18next": "^11.18.6",
     "react-icons": "^4.4.0",
+    "salted-md5": "^4.0.5",
     "shvl": "^3.0.0",
     "swr": "^1.3.0",
     "systeminformation": "^5.17.12",
@@ -57,4 +58,4 @@
   "optionalDependencies": {
     "osx-temperature-sensor": "^1.0.8"
   }
-}
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 344998b0..86188aa5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -58,6 +58,9 @@ dependencies:
   react-icons:
     specifier: ^4.4.0
     version: 4.8.0(react@18.2.0)
+  salted-md5:
+    specifier: ^4.0.5
+    version: 4.0.5
   shvl:
     specifier: ^3.0.0
     version: 3.0.0
@@ -2977,6 +2980,10 @@ packages:
     resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
     dev: false
 
+  /salted-md5@4.0.5:
+    resolution: {integrity: sha512-MPjdfYzyirx0dG0JIaSbkh6PcleRplLY4sDgFwa+t/GiH2saBVbaR8mVTWa9BFg7SkFik1dVBt8BD7tc5To1Mg==}
+    dev: false
+
   /sax@1.2.4:
     resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
     dev: false
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 7f1a86de..a8ecf141 100755
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -92,7 +92,7 @@
         "episodes": "Episodes",
         "songs": "Songs"
     },
-	"evcc": {
+    "evcc": {
         "pv_power": "Production",
         "battery_soc": "Battery",
         "grid_power": "Grid",
@@ -649,5 +649,9 @@
     "whatsupdocker": {
         "monitoring": "Monitoring",
         "updates": "Updates"
+    },
+    "nextpvr": {
+        "upcoming": "Upcoming Recordings",
+        "ready": "Recent Recordings"
     }
-}
+}
\ No newline at end of file
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 589a93ad..9916d601 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -46,6 +46,7 @@ const components = {
   navidrome: dynamic(() => import("./navidrome/component")),
   nextcloud: dynamic(() => import("./nextcloud/component")),
   nextdns: dynamic(() => import("./nextdns/component")),
+  nextpvr: dynamic(() => import("./nextpvr/component")),
   npm: dynamic(() => import("./npm/component")),
   nzbget: dynamic(() => import("./nzbget/component")),
   octoprint: dynamic(() => import("./octoprint/component")),
diff --git a/src/widgets/nextpvr/component.jsx b/src/widgets/nextpvr/component.jsx
new file mode 100644
index 00000000..5549fe59
--- /dev/null
+++ b/src/widgets/nextpvr/component.jsx
@@ -0,0 +1,35 @@
+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: nextpvrData, error: nextpvrAPIError } = useWidgetAPI(widget, "unified", {
+    refreshInterval: 5000,
+  });
+
+  if (nextpvrAPIError) {
+    return <Container service={service} error={nextpvrAPIError} />;
+  }
+
+  if (!nextpvrData) {
+    return (
+      <Container service={service}>
+        <Block label="nextpvr.upcoming" />
+        <Block label="nextpvr.ready" />
+      </Container>
+    );
+  }
+
+  return (
+    <Container service={service}>
+      <Block label="nextpvr.upcoming" value={t("common.number", { value: nextpvrData.recordingCount })} />
+      <Block label="nextpvr.ready" value={t("common.number", { value: nextpvrData.readyCount })} />
+    </Container>
+  );
+}
diff --git a/src/widgets/nextpvr/proxy.js b/src/widgets/nextpvr/proxy.js
new file mode 100644
index 00000000..8b8ad015
--- /dev/null
+++ b/src/widgets/nextpvr/proxy.js
@@ -0,0 +1,153 @@
+/* eslint-disable no-underscore-dangle */
+import { xml2json } from "xml-js";
+
+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 saltedMd5 = require('salted-md5');
+const proxyName = "nextpvrProxyHandler";
+
+const logger = createLogger(proxyName);
+let globalSid = null;
+
+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 loginToNextPVR(endpoint, widget) {
+    const api = widgets?.[widget.type]?.api;
+    if (!api) {
+        return [403, null];
+    }
+    // Create new session on NextPVR
+    let url = new URL(formatApiCall(api, { endpoint, ...widget }));
+
+    let [status, contentType, data] = await httpProxy(url);
+
+    if (status !== 200) {
+        logger.error("HTTP %d communicating with NextPVR. Data: %s", status, data.toString());
+        return [status, data];
+    }
+    let dataAsJson;
+    try {
+        const dataDecoded = xml2json(data.toString(), { compact: true });
+        dataAsJson = JSON.parse(dataDecoded);
+    } catch (e) {
+        logger.error("Error decoding NextPVR API data. Data: %s", data.toString());
+        return [status, null];
+    }
+    // Create md5 hash of pin / salt to to md5 login
+    let hashedSalt = saltedMd5(':' + saltedMd5(widget.pin) + ':', dataAsJson.rsp.salt._text);
+    endpoint = 'session.login&md5='
+    url = new URL(formatApiCall(api, { endpoint, ...widget })) + hashedSalt + '&sid=' + dataAsJson.rsp.sid._text;
+
+    [status, contentType, data] = await httpProxy(url);
+    if (status !== 200) {
+        logger.error("HTTP %d communicating with NextPVR. Data: %s", status, data.toString());
+        return [status, data];
+    }
+    try {
+        const dataDecoded = xml2json(data.toString(), { compact: true });
+        let dataAsJson = JSON.parse(dataDecoded);
+        // Store the session id globally
+        globalSid = dataAsJson.rsp.sid._text
+    } catch (e) {
+        logger.error("Error decoding NextPVR API data. Data: %s", data.toString());
+        return [status, null];
+    }
+    console.log('gettingSID')
+}
+
+
+async function fetchFromNextPVRAPI(endpoint, widget, sid) {
+    const api = widgets?.[widget.type]?.api;
+    if (!api) {
+        return [403, null];
+    }
+
+    const url = new URL(formatApiCall(api, { endpoint, ...widget })) + '&sid=' + sid;
+
+    const [status, contentType, data] = await httpProxy(url);
+
+    if (status !== 200) {
+        logger.error("HTTP %d communicating with NextPVR. Data: %s", status, data.toString());
+        return [status, data];
+    }
+
+    try {
+        const dataDecoded = xml2json(data.toString(), { compact: true });
+        return [status, JSON.parse(dataDecoded), contentType];
+    } catch (e) {
+        logger.error("Error decoding NextPVR API data. Data: %s", data.toString());
+        return [status, null];
+    }
+}
+
+export default async function nextPVRProxyHandler(req, res) {
+    const widget = await getWidget(req);
+
+    if (!globalSid) {
+    await loginToNextPVR('session.initiate&ver=1.0&device=homepage', widget);
+    }
+    if (!widget) {
+        return res.status(400).json({ error: "Invalid proxy service type" });
+    }
+
+    logger.debug("Getting streams from NextPVR API");
+    // Calculate the number of upcoming recordings
+    let [status, apiData] = await fetchFromNextPVRAPI('recording.list', widget, globalSid);
+
+    if (status !== 200) {
+        return res.status(status).json({ error: { message: "HTTP error communicating with NextPVR API", data: Buffer.from(apiData).toString() } });
+    }
+    
+    let recordingCount
+    if (Array.isArray(apiData.rsp.recordings.recording) == false) {
+        if (apiData.rsp.recordings.recording) {
+            recordingCount = 1;
+        } else {
+            recordingCount = 0;
+        }
+    } else {
+        recordingCount = apiData.rsp.recordings.recording.length;
+    }
+    // Calculate the number of ready recordings
+    [status, apiData] = await fetchFromNextPVRAPI('recording.list&filter=ready', widget, globalSid);
+
+    if (status !== 200) {
+        return res.status(status).json({ error: { message: "HTTP error communicating with NextPVR API", data: Buffer.from(apiData).toString() } });
+    }
+    let readyCount
+    if (Array.isArray(apiData.rsp.recordings.recording) == false) {
+        if (apiData.rsp.recordings.recording) {
+            readyCount = 1;
+        } else {
+            readyCount = 0;
+        }
+    } else {
+        readyCount = apiData.rsp.recordings.recording.length;
+    }
+    const data = {
+        recordingCount,
+        readyCount
+    };
+
+    return res.status(status).send(data);
+
+}
+
+
diff --git a/src/widgets/nextpvr/widget.js b/src/widgets/nextpvr/widget.js
new file mode 100644
index 00000000..af4e3421
--- /dev/null
+++ b/src/widgets/nextpvr/widget.js
@@ -0,0 +1,14 @@
+import nextpvrProxyHandler from "./proxy";
+
+const widget = {
+  api: "{url}/service?method={endpoint}",
+  proxyHandler: nextpvrProxyHandler,
+
+  mappings: {
+    unified: {
+      endpoint: "/",
+    },
+  },
+};
+
+export default widget;
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index f843a168..ff770939 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -40,6 +40,7 @@ import mylar from "./mylar/widget";
 import navidrome from "./navidrome/widget";
 import nextcloud from "./nextcloud/widget";
 import nextdns from "./nextdns/widget";
+import nextpvr from "./nextpvr/widget";
 import npm from "./npm/widget";
 import nzbget from "./nzbget/widget";
 import octoprint from "./octoprint/widget";
@@ -128,6 +129,7 @@ const widgets = {
   navidrome,
   nextcloud,
   nextdns,
+  nextpvr,
   npm,
   nzbget,
   octoprint,