NextPVR Service Widget

This commit is contained in:
Karl Hudgell 2023-05-23 12:07:49 +01:00
parent b960813ed9
commit ad10b48204
8 changed files with 220 additions and 3 deletions

View File

@ -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"
}
}
}

7
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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"
}
}
}

View File

@ -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")),

View File

@ -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>
);
}

View File

@ -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);
}

View File

@ -0,0 +1,14 @@
import nextpvrProxyHandler from "./proxy";
const widget = {
api: "{url}/service?method={endpoint}",
proxyHandler: nextpvrProxyHandler,
mappings: {
unified: {
endpoint: "/",
},
},
};
export default widget;

View File

@ -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,