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,