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 ;
+ }
+
+ if (!nextpvrData) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
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,