diff --git a/docs/widgets/services/tvheadend.md b/docs/widgets/services/tvheadend.md new file mode 100644 index 00000000..15e917ed --- /dev/null +++ b/docs/widgets/services/tvheadend.md @@ -0,0 +1,19 @@ +--- +title: Tvheadend +description: Tvheadend Widget Configuration +--- + +Shows the status of the DVR feature (upcoming, finished, failed recordings) as well as a list of active subscriptions. + +Learn more about [Tvheadend](https://tvheadend.org/). + +Allowed fields: `["upcoming", "finished", "failed"]`. + +```yaml +widget: + type: tvheadend + url: http://tvheadend.host.or.ip + username: user + password: pass + fields: ["upcoming", "finished", "failed"] +``` diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 484f76b5..63e49ac6 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -1007,5 +1007,10 @@ "issues": "Issues", "merges": "Merge Requests", "projects": "Projects" + }, + "tvheadend": { + "upcoming": "Upcoming", + "finished": "Finished", + "failed": "Failed" } } diff --git a/src/widgets/components.js b/src/widgets/components.js index 19f41d4a..e5f954b9 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -127,6 +127,7 @@ const components = { transmission: dynamic(() => import("./transmission/component")), tubearchivist: dynamic(() => import("./tubearchivist/component")), truenas: dynamic(() => import("./truenas/component")), + tvheadend: dynamic(() => import("./tvheadend/component")), unifi: dynamic(() => import("./unifi/component")), unmanic: dynamic(() => import("./unmanic/component")), uptimekuma: dynamic(() => import("./uptimekuma/component")), diff --git a/src/widgets/tvheadend/component.jsx b/src/widgets/tvheadend/component.jsx new file mode 100644 index 00000000..16cf0063 --- /dev/null +++ b/src/widgets/tvheadend/component.jsx @@ -0,0 +1,89 @@ +import { useTranslation } from "next-i18next"; + +import Container from "components/services/widget/container"; +import Block from "components/services/widget/block"; +import useWidgetAPI from "utils/proxy/use-widget-api"; +import Subscription from "widgets/tvheadend/subscription"; + +function timeAgo(ticks) { + const now = Date.now(); // Current time in milliseconds + const inputTime = ticks * 1000; // Convert the input ticks from seconds to milliseconds + const difference = now - inputTime; // Difference in milliseconds + + const seconds = Math.floor((difference / 1000) % 60); + const minutes = Math.floor((difference / (1000 * 60)) % 60); + const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); + return { hours, minutes, seconds }; +} + +function timeAgoToString(ticks) { + const { hours, minutes, seconds } = timeAgo(ticks); + const parts = []; + if (hours > 0) { + parts.push(hours); + } + parts.push(minutes); + parts.push(seconds); + + return parts.map((part) => part.toString().padStart(2, "0")).join(":"); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + const { widget } = service; + + const { data: dvrData, error: dvrError } = useWidgetAPI(widget, "dvr", { + refreshInterval: 60000, + }); + const { data: subscriptionsData, error: subscriptionsError } = useWidgetAPI(widget, "subscriptions", { + refreshInterval: 5000, + }); + + if (dvrError || subscriptionsError) { + const finalError = dvrError ?? subscriptionsError; + return ; + } + + if (!dvrData || !subscriptionsData) { + return ( + + + + + + ); + } + + const upcomingCount = dvrData.entries.filter((entry) => entry.sched_status === "scheduled").length; + const finishedCount = dvrData.entries.filter((entry) => entry.sched_status === "completed").length; + const failedCount = dvrData.entries.filter((entry) => entry.sched_status === "failed").length; + + const hasSubscriptions = Array.isArray(subscriptionsData.entries) && subscriptionsData.entries.length > 0; + + return ( + <> + + + + + + {hasSubscriptions && + subscriptionsData.entries + .filter( + (entry) => + entry.channel && + entry.id && + entry.start && // Only include valid entries + entry.state === "Running", // and being watched (Idle=downloading) + ) + .sort((a, b) => a.channel.localeCompare(b.channel)) + .map((subscription) => ( + + ))} + + ); +} diff --git a/src/widgets/tvheadend/subscription.jsx b/src/widgets/tvheadend/subscription.jsx new file mode 100644 index 00000000..38441545 --- /dev/null +++ b/src/widgets/tvheadend/subscription.jsx @@ -0,0 +1,11 @@ +export default function Subscription({ channel, sinceStart }) { + return ( +
+
+
{channel}
+
+
{sinceStart}
+
+
+ ); +} diff --git a/src/widgets/tvheadend/widget.js b/src/widgets/tvheadend/widget.js new file mode 100644 index 00000000..5acf68b2 --- /dev/null +++ b/src/widgets/tvheadend/widget.js @@ -0,0 +1,19 @@ +import genericProxyHandler from "utils/proxy/handlers/generic"; + +const widget = { + api: "{url}/api/{endpoint}", + proxyHandler: genericProxyHandler, + + mappings: { + dvr: { + endpoint: "dvr/entry/grid", + validate: ["entries"], + }, + subscriptions: { + endpoint: "status/subscriptions", + validate: ["entries"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 9d4bb935..14af7104 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -118,6 +118,7 @@ import traefik from "./traefik/widget"; import transmission from "./transmission/widget"; import tubearchivist from "./tubearchivist/widget"; import truenas from "./truenas/widget"; +import tvheadend from "./tvheadend/widget"; import unifi from "./unifi/widget"; import unmanic from "./unmanic/widget"; import uptimekuma from "./uptimekuma/widget"; @@ -255,6 +256,7 @@ const widgets = { transmission, tubearchivist, truenas, + tvheadend, unifi, unifi_console: unifi, unmanic,