From ad10b4820474d9bf70ddcef1fcaf14418f0eeba3 Mon Sep 17 00:00:00 2001 From: Karl Hudgell Date: Tue, 23 May 2023 12:07:49 +0100 Subject: [PATCH 1/3] 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 ; + } + + 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, From 70ee750e6b3ef747149c319b57716b5049bd4fad Mon Sep 17 00:00:00 2001 From: Karl Hudgell Date: Tue, 23 May 2023 12:43:45 +0100 Subject: [PATCH 2/3] fix linting errors --- src/widgets/nextpvr/proxy.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/widgets/nextpvr/proxy.js b/src/widgets/nextpvr/proxy.js index 8b8ad015..a333ae9e 100644 --- a/src/widgets/nextpvr/proxy.js +++ b/src/widgets/nextpvr/proxy.js @@ -6,7 +6,9 @@ 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); @@ -51,25 +53,25 @@ async function loginToNextPVR(endpoint, widget) { 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; + const hashedSalt = saltedMd5(`:${saltedMd5(widget.pin)}:`, dataAsJson.rsp.salt._text); + url = `${new URL(formatApiCall(api, { 'endpoint': 'session.login&md5=', 'url': widget.url }))}${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()); + logger.error("HTTP %d communicating with NextPVR. Data: %s", status, contentType, data.toString()); return [status, data]; } try { const dataDecoded = xml2json(data.toString(), { compact: true }); - let dataAsJson = JSON.parse(dataDecoded); + 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') + logger.info('gettingSID') + return [status, true]; } @@ -78,9 +80,7 @@ async function fetchFromNextPVRAPI(endpoint, widget, sid) { if (!api) { return [403, null]; } - - const url = new URL(formatApiCall(api, { endpoint, ...widget })) + '&sid=' + sid; - + const url = `${new URL(formatApiCall(api, { endpoint, ...widget }))}&sid=${sid}` const [status, contentType, data] = await httpProxy(url); if (status !== 200) { @@ -101,7 +101,7 @@ export default async function nextPVRProxyHandler(req, res) { const widget = await getWidget(req); if (!globalSid) { - await loginToNextPVR('session.initiate&ver=1.0&device=homepage', widget); + await loginToNextPVR('session.initiate&ver=1.0&device=homepage', widget); } if (!widget) { return res.status(400).json({ error: "Invalid proxy service type" }); @@ -114,9 +114,9 @@ export default async function nextPVRProxyHandler(req, res) { 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 (Array.isArray(apiData.rsp.recordings.recording) === false) { if (apiData.rsp.recordings.recording) { recordingCount = 1; } else { @@ -132,7 +132,7 @@ export default async function nextPVRProxyHandler(req, res) { 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 (Array.isArray(apiData.rsp.recordings.recording) === false) { if (apiData.rsp.recordings.recording) { readyCount = 1; } else { From 4700c52283610eeda35f508b96e68e72bd0c33e0 Mon Sep 17 00:00:00 2001 From: Karl Hudgell Date: Tue, 23 May 2023 12:57:53 +0100 Subject: [PATCH 3/3] update locale --- public/locales/en/common.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index a8ecf141..37f7e233 100755 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -651,7 +651,7 @@ "updates": "Updates" }, "nextpvr": { - "upcoming": "Upcoming Recordings", - "ready": "Recent Recordings" + "upcoming": "Upcoming", + "ready": "Recent" } } \ No newline at end of file