From 79b63e4099064935d2391a6addc4baea48b26446 Mon Sep 17 00:00:00 2001 From: muertocaloh <9284052+muertocaloh@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:16:07 +0100 Subject: [PATCH] Feature: Dispatcharr widget (#6035) Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/widgets/services/dispatcharr.md | 17 ++++ docs/widgets/services/index.md | 1 + mkdocs.yml | 1 + public/locales/en/common.json | 4 + src/utils/config/service-helpers.js | 6 ++ src/widgets/components.js | 1 + src/widgets/dispatcharr/component.jsx | 61 +++++++++++++ src/widgets/dispatcharr/proxy.js | 119 ++++++++++++++++++++++++++ src/widgets/dispatcharr/widget.js | 20 +++++ src/widgets/widgets.js | 2 + 10 files changed, 232 insertions(+) create mode 100644 docs/widgets/services/dispatcharr.md create mode 100644 src/widgets/dispatcharr/component.jsx create mode 100644 src/widgets/dispatcharr/proxy.js create mode 100644 src/widgets/dispatcharr/widget.js diff --git a/docs/widgets/services/dispatcharr.md b/docs/widgets/services/dispatcharr.md new file mode 100644 index 000000000..f31fbcdaf --- /dev/null +++ b/docs/widgets/services/dispatcharr.md @@ -0,0 +1,17 @@ +--- +title: Dispatcharr +description: Dispatcharr Widget Configuration +--- + +Learn more about [Dispatcharr](https://github.com/Dispatcharr/Dispatcharr). + +Allowed fields: `["channels", "streams"]`. + +```yaml +widget: + type: dispatcharr + url: http://dispatcharr.host.or.ip + username: username + password: password + enableActiveStreams: true # optional, defaults to false +``` diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md index 190dddec7..8b43802e9 100644 --- a/docs/widgets/services/index.md +++ b/docs/widgets/services/index.md @@ -32,6 +32,7 @@ You can also find a list of all available service widgets in the sidebar navigat - [Deluge](deluge.md) - [DeveLanCacheUI](develancacheui.md) - [DiskStation](diskstation.md) +- [Dispatcharr](dispatcharr.md) - [Dockhand](dockhand.md) - [DownloadStation](downloadstation.md) - [Emby](emby.md) diff --git a/mkdocs.yml b/mkdocs.yml index a7bc0d87b..30dad803f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,7 @@ nav: - widgets/services/deluge.md - widgets/services/develancacheui.md - widgets/services/diskstation.md + - widgets/services/dispatcharr.md - widgets/services/dockhand.md - widgets/services/downloadstation.md - widgets/services/emby.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 8a34ae361..c002e2771 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -705,6 +705,10 @@ "uptime": "Uptime", "volumeAvailable": "Available" }, + "dispatcharr": { + "channels": "Channels", + "streams": "Streams" + }, "mylar": { "series": "Series", "issues": "Issues", diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 95cdc7538..42e85243b 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -294,6 +294,9 @@ export function cleanServiceGroups(groups) { // diskstation volume, + // dispatcharr + enableActiveStreams, + // docker container, server, @@ -547,6 +550,9 @@ export function cleanServiceGroups(groups) { if (["diskstation", "qnap"].includes(type)) { if (volume) widget.volume = volume; } + if (["dispatcharr"].includes(type)) { + if (enableActiveStreams) widget.enableActiveStreams = !!JSON.parse(enableActiveStreams); + } if (type === "gamedig") { if (gameToken) widget.gameToken = gameToken; } diff --git a/src/widgets/components.js b/src/widgets/components.js index 7cb304755..e69a985c5 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -27,6 +27,7 @@ const components = { deluge: dynamic(() => import("./deluge/component")), develancacheui: dynamic(() => import("./develancacheui/component")), diskstation: dynamic(() => import("./diskstation/component")), + dispatcharr: dynamic(() => import("./dispatcharr/component")), downloadstation: dynamic(() => import("./downloadstation/component")), docker: dynamic(() => import("./docker/component")), dockhand: dynamic(() => import("./dockhand/component")), diff --git a/src/widgets/dispatcharr/component.jsx b/src/widgets/dispatcharr/component.jsx new file mode 100644 index 000000000..29a6c0936 --- /dev/null +++ b/src/widgets/dispatcharr/component.jsx @@ -0,0 +1,61 @@ +import Block from "components/services/widget/block"; +import Container from "components/services/widget/container"; +import { useTranslation } from "next-i18next"; + +import useWidgetAPI from "utils/proxy/use-widget-api"; + +function StreamEntry({ title, clients, bitrate }) { + return ( +
+
+
+ {title} - Clients: {clients} +
+
+
+ {bitrate} +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + + const { data: channels, error: channelsError } = useWidgetAPI(widget, "channels"); + const { data: streams, error: streamsError } = useWidgetAPI(widget, "streams"); + + if (channelsError || streamsError) { + return ; + } + + if (!channels || !streams) { + return ( + + + + + ); + } + + return ( + <> + + + + + {widget?.enableActiveStreams && + streams?.channels && + streams.channels.map((activeStream) => ( + + ))} + + ); +} diff --git a/src/widgets/dispatcharr/proxy.js b/src/widgets/dispatcharr/proxy.js new file mode 100644 index 000000000..45175983a --- /dev/null +++ b/src/widgets/dispatcharr/proxy.js @@ -0,0 +1,119 @@ +import cache from "memory-cache"; + +import getServiceWidget from "utils/config/service-helpers"; +import createLogger from "utils/logger"; +import { formatApiCall } from "utils/proxy/api-helpers"; +import { httpProxy } from "utils/proxy/http"; +import widgets from "widgets/widgets"; + +const proxyName = "dispatcharrProxyHandler"; +const tokenCacheKey = `${proxyName}__token`; +const logger = createLogger(proxyName); + +async function login(loginUrl, username, password, service) { + const authResponse = await httpProxy(loginUrl, { + method: "POST", + body: JSON.stringify({ username, password }), + headers: { + "Content-Type": "application/json", + }, + }); + + const status = authResponse[0]; + let data = authResponse[2]; + try { + data = JSON.parse(Buffer.from(authResponse[2]).toString()); + + if (status === 200) { + cache.put(`${tokenCacheKey}.${service}`, data.access); + } else { + throw new Error(`HTTP ${status} logging into dispatcharr`); + } + } catch (e) { + logger.error(`Error ${status} logging into dispatcharr`, JSON.stringify(data)); + return [status, null]; + } + return [status, data.access]; +} + +export default async function dispatcharrProxyHandler(req, res) { + const { group, service, endpoint, index } = req.query; + + if (group && service) { + const widget = await getServiceWidget(group, service, index); + + if (!widgets?.[widget.type]?.api) { + return res.status(403).json({ error: "Service does not support API calls" }); + } + + if (widget) { + const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint, ...widget })); + const loginUrl = formatApiCall(widgets[widget.type].api, { + endpoint: widgets[widget.type].mappings["token"].endpoint, + ...widget, + }); + + let status; + let data; + + let token = cache.get(`${tokenCacheKey}.${service}`); + if (!token) { + [status, token] = await login(loginUrl, widget.username, widget.password, service); + if (!token) { + logger.debug(`HTTP ${status} logging into Dispatcharr}`); + return res.status(status).send({ error: "Failed to authenticate with Dispatcharr" }); + } + } + + [status, , data] = await httpProxy(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + const badRequest = [400, 401, 403].includes(status); + let isEmpty = false; + + try { + const json = JSON.parse(data.toString("utf-8")); + isEmpty = Array.isArray(json.items) && json.items.length === 0; + } catch (err) { + logger.error("Failed to parse Dispatcharr response JSON:", err); + } + + if (badRequest || isEmpty) { + if (badRequest) { + logger.debug(`HTTP ${status} retrieving data from Dispatcharr, logging in and trying again.`); + } else { + logger.debug(`Received empty list from Dispatcharr, logging in and trying again.`); + } + cache.del(`${tokenCacheKey}.${service}`); + [status, token] = await login(loginUrl, widget.username, widget.password, service); + + if (status !== 200) { + logger.debug(`HTTP ${status} logging into Dispatcharr: ${JSON.stringify(data)}`); + return res.status(status).send(data); + } + + // eslint-disable-next-line no-unused-vars + [status, , data] = await httpProxy(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + } + + if (status !== 200) { + return res.status(status).send(data); + } + + return res.send(data); + } + } + + return res.status(400).json({ error: "Invalid proxy service type" }); +} diff --git a/src/widgets/dispatcharr/widget.js b/src/widgets/dispatcharr/widget.js new file mode 100644 index 000000000..6e69b7aff --- /dev/null +++ b/src/widgets/dispatcharr/widget.js @@ -0,0 +1,20 @@ +import dispatcharrProxyHandler from "./proxy"; + +const widget = { + api: "{url}/{endpoint}", + proxyHandler: dispatcharrProxyHandler, + + mappings: { + token: { + endpoint: "api/accounts/token/", + }, + channels: { + endpoint: "api/channels/channels/", + }, + streams: { + endpoint: "proxy/ts/status", + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index eeeae0cc1..2029a82c6 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -23,6 +23,7 @@ import customapi from "./customapi/widget"; import deluge from "./deluge/widget"; import develancacheui from "./develancacheui/widget"; import diskstation from "./diskstation/widget"; +import dispatcharr from "./dispatcharr/widget"; import dockhand from "./dockhand/widget"; import downloadstation from "./downloadstation/widget"; import emby from "./emby/widget"; @@ -172,6 +173,7 @@ const widgets = { deluge, develancacheui, diskstation, + dispatcharr, dockhand, downloadstation, emby,