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