From 808e79e2acf5eddbd16c239ca56aa07bc624aa2a Mon Sep 17 00:00:00 2001 From: Jason Fischer Date: Sun, 25 Sep 2022 16:15:47 -0700 Subject: [PATCH] Add Docker, Emby, Gotify, Jackett, and JellySeerr widgets --- .../services/widgets/service/docker.jsx | 2 +- src/pages/api/services/proxy.js | 14 +- src/utils/api-helpers.js | 19 +- src/widgets/components.js | 6 + src/widgets/docker/component.jsx | 63 +++++ .../docker}/stats-helpers.js | 0 src/widgets/emby/component.jsx | 239 ++++++++++++++++++ src/widgets/emby/widget.js | 19 ++ src/widgets/gotify/component.jsx | 28 ++ src/widgets/gotify/widget.js | 20 ++ src/widgets/jackett/component.jsx | 36 +++ src/widgets/jackett/widget.js | 14 + src/widgets/jellyseerr/component.jsx | 36 +++ src/widgets/jellyseerr/widget.js | 14 + src/widgets/widgets.js | 9 + 15 files changed, 510 insertions(+), 9 deletions(-) create mode 100644 src/widgets/docker/component.jsx rename src/{utils => widgets/docker}/stats-helpers.js (100%) create mode 100644 src/widgets/emby/component.jsx create mode 100644 src/widgets/emby/widget.js create mode 100644 src/widgets/gotify/component.jsx create mode 100644 src/widgets/gotify/widget.js create mode 100644 src/widgets/jackett/component.jsx create mode 100644 src/widgets/jackett/widget.js create mode 100644 src/widgets/jellyseerr/component.jsx create mode 100644 src/widgets/jellyseerr/widget.js diff --git a/src/components/services/widgets/service/docker.jsx b/src/components/services/widgets/service/docker.jsx index 89ac73c8b..ca9476db9 100644 --- a/src/components/services/widgets/service/docker.jsx +++ b/src/components/services/widgets/service/docker.jsx @@ -4,7 +4,7 @@ import { useTranslation } from "next-i18next"; import Widget from "../widget"; import Block from "../block"; -import calculateCPUPercent from "utils/stats-helpers"; +import calculateCPUPercent from "widgets/docker/stats-helpers"; export default function Docker({ service }) { const { t } = useTranslation(); diff --git a/src/pages/api/services/proxy.js b/src/pages/api/services/proxy.js index 2efb01c26..2db0d8faa 100644 --- a/src/pages/api/services/proxy.js +++ b/src/pages/api/services/proxy.js @@ -1,3 +1,4 @@ +import { formatApiCall } from "utils/api-helpers"; import createLogger from "utils/logger"; import genericProxyHandler from "utils/proxies/generic"; import widgets from "widgets/widgets"; @@ -31,12 +32,15 @@ export default async function handler(req, res) { return res.status(403).json({ error: "Unsupported service endpoint" }); } - if (req.query.params) { - const queryParams = JSON.parse(req.query.params); + req.query.endpoint = endpoint; + if (req.query.segments) { + const segments = JSON.parse(req.query.segments); + req.query.endpoint = formatApiCall(endpoint, segments); + } + if (req.query.query) { + const queryParams = JSON.parse(req.query.query); const query = new URLSearchParams(mappingParams.map((p) => [p, queryParams[p]])); - req.query.endpoint = `${endpoint}?${query}`; - } else { - req.query.endpoint = endpoint; + req.query.endpoint = `${req.query.endpoint}?${query}`; } if (endpointProxy instanceof Function) { diff --git a/src/utils/api-helpers.js b/src/utils/api-helpers.js index 6865824f5..9293e9872 100644 --- a/src/utils/api-helpers.js +++ b/src/utils/api-helpers.js @@ -32,15 +32,28 @@ export function formatApiCall(url, args) { return url.replace(find, replace); } -export function formatProxyUrl(widget, endpoint, endpointParams) { +function getURLSearchParams(widget, endpoint) { const params = new URLSearchParams({ type: widget.type, group: widget.service_group, service: widget.service_name, endpoint, }); - if (endpointParams) { - params.append("params", JSON.stringify(endpointParams)); + return params; +} + +export function formatProxyUrlWithSegments(widget, endpoint, segments) { + const params = getURLSearchParams(widget, endpoint); + if (segments) { + params.append("segments", JSON.stringify(segments)) + } + return `/api/services/proxy?${params.toString()}`; +} + +export function formatProxyUrl(widget, endpoint, queryParams) { + const params = getURLSearchParams(widget, endpoint); + if (queryParams) { + params.append("query", JSON.stringify(queryParams)); } return `/api/services/proxy?${params.toString()}`; } diff --git a/src/widgets/components.js b/src/widgets/components.js index 07b1e1fa1..0831ba7d5 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -4,6 +4,12 @@ const components = { adguard: dynamic(() => import("./adguard/component")), bazarr: dynamic(() => import("./bazarr/component")), coinmarketcap: dynamic(() => import("./coinmarketcap/component")), + docker: dynamic(() => import("./docker/component")), + emby: dynamic(() => import("./emby/component")), + gotify: dynamic(() => import("./gotify/component")), + jackett: dynamic(() => import("./jackett/component")), + jellyfin: dynamic(() => import("./emby/component")), + jellyseerr: dynamic(() => import("./jellyseerr/component")), overseerr: dynamic(() => import("./overseerr/component")), portainer: dynamic(() => import("./portainer/component")), prowlarr: dynamic(() => import("./prowlarr/component")), diff --git a/src/widgets/docker/component.jsx b/src/widgets/docker/component.jsx new file mode 100644 index 000000000..bf015060b --- /dev/null +++ b/src/widgets/docker/component.jsx @@ -0,0 +1,63 @@ +import useSWR from "swr"; +import { useTranslation } from "next-i18next"; + +import calculateCPUPercent from "./stats-helpers"; + +import Widget from "components/services/widgets/widget"; +import Block from "components/services/widgets/block"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: statusData, error: statusError } = useSWR( + `/api/docker/status/${config.container}/${config.server || ""}`, + { + refreshInterval: 5000, + } + ); + + const { data: statsData, error: statsError } = useSWR( + `/api/docker/stats/${config.container}/${config.server || ""}`, + { + refreshInterval: 5000, + } + ); + + if (statsError || statusError) { + return ; + } + + if (statusData && statusData.status !== "running") { + return ( + + + + ); + } + + if (!statsData || !statusData) { + return ( + + + + + + + ); + } + + return ( + + + + {statsData.stats.networks && ( + <> + + + + )} + + ); +} diff --git a/src/utils/stats-helpers.js b/src/widgets/docker/stats-helpers.js similarity index 100% rename from src/utils/stats-helpers.js rename to src/widgets/docker/stats-helpers.js diff --git a/src/widgets/emby/component.jsx b/src/widgets/emby/component.jsx new file mode 100644 index 000000000..5096174da --- /dev/null +++ b/src/widgets/emby/component.jsx @@ -0,0 +1,239 @@ +import useSWR from "swr"; +import { useTranslation } from "next-i18next"; +import { BsVolumeMuteFill, BsFillPlayFill, BsPauseFill, BsCpu, BsFillCpuFill } from "react-icons/bs"; +import { MdOutlineSmartDisplay } from "react-icons/md"; + +import Widget from "components/services/widgets/widget"; +import { formatProxyUrl, formatProxyUrlWithSegments } from "utils/api-helpers"; + +function ticksToTime(ticks) { + const milliseconds = ticks / 10000; + const seconds = Math.floor((milliseconds / 1000) % 60); + const minutes = Math.floor((milliseconds / (1000 * 60)) % 60); + const hours = Math.floor((milliseconds / (1000 * 60 * 60)) % 24); + return { hours, minutes, seconds }; +} + +function ticksToString(ticks) { + const { hours, minutes, seconds } = ticksToTime(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(":"); +} + +function SingleSessionEntry({ playCommand, session }) { + const { + NowPlayingItem: { Name, SeriesName, RunTimeTicks }, + PlayState: { PositionTicks, IsPaused, IsMuted }, + } = session; + + const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || { + IsVideoDirect: true, + VideoDecoderIsHardware: true, + VideoEncoderIsHardware: true, + }; + + const percent = (PositionTicks / RunTimeTicks) * 100; + + return ( + <> +
+
+
+ {Name} + {SeriesName && ` - ${SeriesName}`} +
+
+
+ {IsVideoDirect && } + {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && } + {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && ( + + )} +
+
+ +
+
+
+ {IsPaused && ( + { + playCommand(session, "Unpause"); + }} + className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" + /> + )} + {!IsPaused && ( + { + playCommand(session, "Pause"); + }} + className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" + /> + )} +
+
+
{IsMuted && }
+
+ {ticksToString(PositionTicks)} + / + {ticksToString(RunTimeTicks)} +
+
+ + ); +} + +function SessionEntry({ playCommand, session }) { + const { + NowPlayingItem: { Name, SeriesName, RunTimeTicks }, + PlayState: { PositionTicks, IsPaused, IsMuted }, + } = session; + + const { IsVideoDirect, VideoDecoderIsHardware, VideoEncoderIsHardware } = session?.TranscodingInfo || {}; + + const percent = (PositionTicks / RunTimeTicks) * 100; + + return ( +
+
+
+ {IsPaused && ( + { + playCommand(session, "Unpause"); + }} + className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" + /> + )} + {!IsPaused && ( + { + playCommand(session, "Pause"); + }} + className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" + /> + )} +
+
+
+ {Name} + {SeriesName && ` - ${SeriesName}`} +
+
+
{IsMuted && }
+
{ticksToString(PositionTicks)}
+
+ {IsVideoDirect && } + {!IsVideoDirect && (!VideoDecoderIsHardware || !VideoEncoderIsHardware) && } + {!IsVideoDirect && VideoDecoderIsHardware && VideoEncoderIsHardware && } +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { + data: sessionsData, + error: sessionsError, + mutate: sessionMutate, + } = useSWR(formatProxyUrl(config, "Sessions"), { + refreshInterval: 5000, + }); + + async function handlePlayCommand(session, command) { + const url = formatProxyUrlWithSegments(config, "PlayControl", { + sessionId: session.Id, + command + }); + await fetch(url).then(() => { + sessionMutate(); + }); + } + + if (sessionsError) { + return ; + } + + if (!sessionsData) { + return ( +
+
+ - +
+
+ - +
+
+ ); + } + + const playing = sessionsData + .filter((session) => session?.NowPlayingItem) + .sort((a, b) => { + if (a.PlayState.PositionTicks > b.PlayState.PositionTicks) { + return 1; + } + if (a.PlayState.PositionTicks < b.PlayState.PositionTicks) { + return -1; + } + return 0; + }); + + if (playing.length === 0) { + return ( +
+
+ {t("emby.no_active")} +
+
+ - +
+
+ ); + } + + if (playing.length === 1) { + const session = playing[0]; + return ( +
+ handlePlayCommand(currentSession, command)} + session={session} + /> +
+ ); + } + + return ( +
+ {playing.map((session) => ( + handlePlayCommand(currentSession, command)} + session={session} + /> + ))} +
+ ); +} diff --git a/src/widgets/emby/widget.js b/src/widgets/emby/widget.js new file mode 100644 index 000000000..421575221 --- /dev/null +++ b/src/widgets/emby/widget.js @@ -0,0 +1,19 @@ +import genericProxyHandler from "utils/proxies/generic"; + +const widget = { + api: "{url}/emby/{endpoint}?api_key={key}", + proxyHandler: genericProxyHandler, + + mappings: { + "Sessions": { + endpoint: "Sessions", + }, + "PlayControl": { + method: "POST", + enpoint: "Sessions/{sessionId}/Playing/{command}", + segments: ["sessionId", "command"] + } + }, +}; + +export default widget; diff --git a/src/widgets/gotify/component.jsx b/src/widgets/gotify/component.jsx new file mode 100644 index 000000000..178dedcaf --- /dev/null +++ b/src/widgets/gotify/component.jsx @@ -0,0 +1,28 @@ +import useSWR from "swr"; +import { useTranslation } from "next-i18next"; + +import Widget from "components/services/widgets/widget"; +import Block from "components/services/widgets/block"; +import { formatProxyUrl } from "utils/api-helpers"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: appsData, error: appsError } = useSWR(formatProxyUrl(config, `application`)); + const { data: messagesData, error: messagesError } = useSWR(formatProxyUrl(config, `message`)); + const { data: clientsData, error: clientsError } = useSWR(formatProxyUrl(config, `client`)); + + if (appsError || messagesError || clientsError) { + return ; + } + + return ( + + + + + + ); +} diff --git a/src/widgets/gotify/widget.js b/src/widgets/gotify/widget.js new file mode 100644 index 000000000..2ad711802 --- /dev/null +++ b/src/widgets/gotify/widget.js @@ -0,0 +1,20 @@ +import credentialedProxyHandler from "utils/proxies/credentialed"; + +const widget = { + api: "{url}/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + "application": { + endpoint: "application" + }, + "client": { + endpoint: "client" + }, + "message": { + endpoint: "message" + }, + }, +}; + +export default widget; diff --git a/src/widgets/jackett/component.jsx b/src/widgets/jackett/component.jsx new file mode 100644 index 000000000..738355fce --- /dev/null +++ b/src/widgets/jackett/component.jsx @@ -0,0 +1,36 @@ +import useSWR from "swr"; +import { useTranslation } from "next-i18next"; + +import Widget from "components/services/widgets/widget"; +import Block from "components/services/widgets/block"; +import { formatProxyUrl } from "utils/api-helpers"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: indexersData, error: indexersError } = useSWR(formatProxyUrl(config, "indexers")); + + if (indexersError) { + return ; + } + + if (!indexersData) { + return ( + + + + + ); + } + + const errored = indexersData.filter((indexer) => indexer.last_error); + + return ( + + + + + ); +} diff --git a/src/widgets/jackett/widget.js b/src/widgets/jackett/widget.js new file mode 100644 index 000000000..d787c3e45 --- /dev/null +++ b/src/widgets/jackett/widget.js @@ -0,0 +1,14 @@ +import genericProxyHandler from "utils/proxies/generic"; + +const widget = { + api: "{url}/api/v2.0/{endpoint}?apikey={key}&configured=true", + proxyHandler: genericProxyHandler, + + mappings: { + "indexers": { + endpoint: "indexers" + }, + }, +}; + +export default widget; diff --git a/src/widgets/jellyseerr/component.jsx b/src/widgets/jellyseerr/component.jsx new file mode 100644 index 000000000..74685ddc1 --- /dev/null +++ b/src/widgets/jellyseerr/component.jsx @@ -0,0 +1,36 @@ +import useSWR from "swr"; +import { useTranslation } from "next-i18next"; + +import Widget from "components/services/widgets/widget"; +import Block from "components/services/widgets/block"; +import { formatProxyUrl } from "utils/api-helpers"; + +export default function Component({ service }) { + const { t } = useTranslation(); + + const config = service.widget; + + const { data: statsData, error: statsError } = useSWR(formatProxyUrl(config, `request/count`)); + + if (statsError) { + return ; + } + + if (!statsData) { + return ( + + + + + + ); + } + + return ( + + + + + + ); +} diff --git a/src/widgets/jellyseerr/widget.js b/src/widgets/jellyseerr/widget.js new file mode 100644 index 000000000..4b823efcc --- /dev/null +++ b/src/widgets/jellyseerr/widget.js @@ -0,0 +1,14 @@ +import credentialedProxyHandler from "utils/proxies/credentialed"; + +const widget = { + api: "{url}/api/v1/{endpoint}", + proxyHandler: credentialedProxyHandler, + + mappings: { + "request/count": { + endpoint: "request/count" + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 78b177261..241c78d91 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -1,6 +1,10 @@ import adguard from "./adguard/widget"; import bazarr from "./bazarr/widget"; import coinmarketcap from "./coinmarketcap/widget"; +import emby from "./emby/widget"; +import gotify from "./gotify/widget"; +import jackett from "./jackett/widget"; +import jellyseerr from "./jellyseerr/widget"; import overseerr from "./overseerr/widget"; import portainer from "./portainer/widget"; import prowlarr from "./prowlarr/widget"; @@ -20,6 +24,11 @@ const widgets = { adguard, bazarr, coinmarketcap, + emby, + gotify, + jackett, + jellyfin: emby, + jellyseerr, overseerr, portainer, prowlarr,