From 65c2aa1b35c41c2d91e268c6ffea573fe78c137b Mon Sep 17 00:00:00 2001 From: brikim Date: Sun, 21 Apr 2024 13:13:35 -0500 Subject: [PATCH] Add a Tautulli History Widget --- docs/widgets/services/plex-tautullihistory.md | 18 +++ mkdocs.yml | 1 + public/locales/en/common.json | 3 + src/utils/config/service-helpers.js | 6 + src/utils/media/platformIcon.jsx | 30 +++++ src/utils/media/playStatusIcon.jsx | 16 +++ src/utils/media/timeToString.jsx | 22 ++++ src/widgets/components.js | 1 + src/widgets/tautulli/component.jsx | 8 +- src/widgets/tautullihistory/component.jsx | 122 ++++++++++++++++++ src/widgets/tautullihistory/widget.js | 15 +++ src/widgets/widgets.js | 2 + 12 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 docs/widgets/services/plex-tautullihistory.md create mode 100644 src/utils/media/platformIcon.jsx create mode 100644 src/utils/media/playStatusIcon.jsx create mode 100644 src/utils/media/timeToString.jsx create mode 100644 src/widgets/tautullihistory/component.jsx create mode 100644 src/widgets/tautullihistory/widget.js diff --git a/docs/widgets/services/plex-tautullihistory.md b/docs/widgets/services/plex-tautullihistory.md new file mode 100644 index 000000000..17671e5ac --- /dev/null +++ b/docs/widgets/services/plex-tautullihistory.md @@ -0,0 +1,18 @@ +--- +title: Tautulli History (Plex) +description: Tautulli History Widget Configuration +--- + +Learn more about [Tautulli](https://github.com/Tautulli/Tautulli). + +Provides a watched history from tautulli. You can find the API key from inside Tautulli at `Settings > Web Interface > API`. + +Allowed fields: no configurable fields for this widget. + +```yaml +widget: + type: tautullihistory + url: http://tautulli.host.or.ip + key: apikeyapikeyapikeyapikeyapikey + maxItems: 10 # optional, defaults to 10 +``` diff --git a/mkdocs.yml b/mkdocs.yml index 6c6668924..06f7a0ad6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - widgets/services/pihole.md - widgets/services/plantit.md - widgets/services/plex-tautulli.md + - widgets/services/plex-tautullihistory.md - widgets/services/plex.md - widgets/services/portainer.md - widgets/services/prometheus.md diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 3ac3ed0d2..e19488cca 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -172,6 +172,9 @@ "no_active": "No Active Streams", "plex_connection_error": "Check Plex Connection" }, + "tautullihistory": { + "no_history": "No History" + }, "omada": { "connectedAp": "Connected APs", "activeUser": "Active devices", diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js index 7fb810881..8fdaeffc2 100644 --- a/src/utils/config/service-helpers.js +++ b/src/utils/config/service-helpers.js @@ -396,6 +396,9 @@ export function cleanServiceGroups(groups) { // emby, jellyfin, tautulli enableUser, + // tautullihistory + maxItems, + // glances, pihole version, @@ -529,6 +532,9 @@ export function cleanServiceGroups(groups) { cleanedService.widget.enableUser = !!JSON.parse(enableUser); } } + if (["tautullihistory"].includes(type)) { + if (maxItems) cleanedService.widget.maxItems = maxItems; + } if (["sonarr", "radarr"].includes(type)) { if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue); } diff --git a/src/utils/media/platformIcon.jsx b/src/utils/media/platformIcon.jsx new file mode 100644 index 000000000..ba385504e --- /dev/null +++ b/src/utils/media/platformIcon.jsx @@ -0,0 +1,30 @@ +import { BsAndroid2, BsApple, BsBrowserChrome, BsBrowserEdge, BsBrowserFirefox, BsPlaystation, BsWindows, BsXbox } from "react-icons/bs"; +import { SiLg, SiLinux, SiOperagx, SiPlex, SiRoku, SiSafari, SiSamsung, SiWii } from "react-icons/si"; + +export default function PlatformIcon({ platform, opacity }) { + return ( +
+
+ {(platform === "android" || platform === "chromecast") && } + {(platform === "apple tv" || platform === "tvos" || platform === "ios" || + platform === "ipad" || platform === "iphone" || platform === "osx" || + platform === "macos") && } + {platform === "chrome" && } + {platform === "firefox" && } + {platform === "linux" && } + {(platform === "microsoft edge" || platform === "internet explorer") && } + {(platform === "netcast" || platform === "webos") && } + {(platform === "opera" || platform === "vizio") && } + {platform === "playstation" && } + {(platform === "plex home theater" || platform === "plex media player" || + platform === "plexamp" || platform === "plextogether") && } + {platform === "roku" && } + {platform === "safari" && } + {(platform === "samsung" || platform === "tizen") && } + {platform === "wiiu" && } + {(platform === "windows" || platform === "windows phone") && } + {platform === "xbox" && } +
+
+ ); +} diff --git a/src/utils/media/playStatusIcon.jsx b/src/utils/media/playStatusIcon.jsx new file mode 100644 index 000000000..eae0e7dd7 --- /dev/null +++ b/src/utils/media/playStatusIcon.jsx @@ -0,0 +1,16 @@ +import { PiCopy, PiCpu, PiCpuFill } from "react-icons/pi"; +import { MdOutlineSmartDisplay } from "react-icons/md"; + +export default function PlayStatusIcon({ videoDecision, audioDecision, opacity }) { + return ( +
+ {videoDecision === "direct play" && audioDecision === "direct play" && ( + + )} + {videoDecision === "copy" && audioDecision === "copy" && } + {videoDecision !== "copy" && videoDecision !== "direct play" && } + {(videoDecision === "copy" || videoDecision === "direct play") && + (audioDecision !== "copy" && audioDecision !== "direct play") && } +
+ ); +} diff --git a/src/utils/media/timeToString.jsx b/src/utils/media/timeToString.jsx new file mode 100644 index 000000000..f65fdd3bf --- /dev/null +++ b/src/utils/media/timeToString.jsx @@ -0,0 +1,22 @@ +function millisecondsToTime(milliseconds) { + 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 }; +} + +export default function MillisecondsToString(milliseconds) { + const { hours, minutes, seconds } = millisecondsToTime(milliseconds); + let timeVal = ""; + if (hours > 0) { + timeVal = hours.toString(); + timeVal += ":"; + timeVal += minutes.toString().padStart(2, "0"); + } + else { + timeVal += minutes.toString(); + } + timeVal += ":"; + timeVal += seconds.toString().padStart(2, "0"); + return timeVal; +} diff --git a/src/widgets/components.js b/src/widgets/components.js index 500fe0ce7..f9231f78e 100644 --- a/src/widgets/components.js +++ b/src/widgets/components.js @@ -106,6 +106,7 @@ const components = { tailscale: dynamic(() => import("./tailscale/component")), tandoor: dynamic(() => import("./tandoor/component")), tautulli: dynamic(() => import("./tautulli/component")), + tautullihistory: dynamic(() => import("./tautullihistory/component")), tdarr: dynamic(() => import("./tdarr/component")), traefik: dynamic(() => import("./traefik/component")), transmission: dynamic(() => import("./transmission/component")), diff --git a/src/widgets/tautulli/component.jsx b/src/widgets/tautulli/component.jsx index d224391b7..6a31e6860 100644 --- a/src/widgets/tautulli/component.jsx +++ b/src/widgets/tautulli/component.jsx @@ -26,7 +26,7 @@ function millisecondsToString(milliseconds) { } function SingleSessionEntry({ session, enableUser }) { - const { full_title, duration, view_offset, progress_percent, state, video_decision, audio_decision, username } = + const { full_title, duration, view_offset, progress_percent, state, video_decision, audio_decision, friendly_name } = session; return ( @@ -35,7 +35,7 @@ function SingleSessionEntry({ session, enableUser }) {
{full_title} - {enableUser && ` (${username})`} + {enableUser && ` (${friendly_name})`}
@@ -79,7 +79,7 @@ function SingleSessionEntry({ session, enableUser }) { } function SessionEntry({ session, enableUser }) { - const { full_title, view_offset, progress_percent, state, video_decision, audio_decision, username } = session; + const { full_title, view_offset, progress_percent, state, video_decision, audio_decision, friendly_name } = session; return (
@@ -100,7 +100,7 @@ function SessionEntry({ session, enableUser }) {
{full_title} - {enableUser && ` (${username})`} + {enableUser && ` (${friendly_name})`}
diff --git a/src/widgets/tautullihistory/component.jsx b/src/widgets/tautullihistory/component.jsx new file mode 100644 index 000000000..bafd1cad6 --- /dev/null +++ b/src/widgets/tautullihistory/component.jsx @@ -0,0 +1,122 @@ +/* eslint-disable camelcase */ +import { useTranslation } from "next-i18next"; +import { DateTime } from "luxon"; +import { useState, useMemo } from "react"; +import { BiCircle, BiSolidCircle, BiSolidCircleHalf, BiSolidCircleQuarter, BiSolidCircleThreeQuarter } from "react-icons/bi"; +import classNames from "classnames"; + +import Container from "components/services/widget/container"; +import MillisecondsToString from "utils/media/timeToString" +import PlatformIcon from "utils/media/platformIcon"; +import PlayStatusIcon from "utils/media/playStatusIcon"; +import useWidgetAPI from "utils/proxy/use-widget-api"; + +function RecordEntry({ record }) { + const [hover, setHover] = useState(false); + const { i18n } = useTranslation(); + const { full_title, platform, player, play_duration, stopped, transcode_decision, friendly_name, watched_status } = record; + + const stoppedDate = DateTime.fromSeconds(stopped); + const key = `record-${full_title}-${stoppedDate}-${friendly_name}`; + + // Requires setHover in each section since hover changes the right hand side + return ( +
+
setHover(true)} + onMouseLeave={() => setHover(false)} + key={key}> +
+
{stoppedDate.setLocale(i18n.language).toLocaleString({ month: "short", day: "numeric" })}
+
+ {platform && } +
+
{friendly_name}
+
+
+
+ {!hover && friendly_name !== "unknown" && +
setHover(true)} + onMouseLeave={() => setHover(false)} + key={key}>{full_title}
+ } + {hover && friendly_name !== "unknown" && +
setHover(true)} + onMouseLeave={() => setHover(false)} + key={key}> +
+ +
+
{player}
+
+
{play_duration && MillisecondsToString(play_duration * 1000)}
+
+
+
+ {watched_status === 0.25 && +
} + {watched_status === 0.5 && +
} + {watched_status === 0.75 && +
} + {watched_status === 1 && +
} +
+
+
+ } +
+
+ ); +} + +export default function Component({ service }) { + const { t } = useTranslation(); + + const { widget } = service; + const maxItems = widget?.maxItems ?? 10; + + // params for API fetch + const params = useMemo(() => { + const constructedParams = { + include_activity: 0, + length: "", + }; + + constructedParams.length = maxItems; + + return constructedParams; + }, [maxItems]); + + const { data: historyData, error: historyError } = useWidgetAPI(widget, "get_history", { ...params }); + + if (historyError || (historyData && Object.keys(historyData.response.data).length === 0)) { + return ; + } + + if (!historyData || historyData.response.data.data.length === 0) { + return ( +
+
+ {t("tautullihistory.no_history")} +
+
+ ); + } + + return ( +
+ { historyData.response.data.data.map((record) => ( + + ))} +
+ ); +} diff --git a/src/widgets/tautullihistory/widget.js b/src/widgets/tautullihistory/widget.js new file mode 100644 index 000000000..5baf55fd9 --- /dev/null +++ b/src/widgets/tautullihistory/widget.js @@ -0,0 +1,15 @@ +import genericProxyHandler from "utils/proxy/handlers/generic"; + +const widget = { + api: "{url}/api/v2?apikey={key}&cmd={endpoint}", + proxyHandler: genericProxyHandler, + + mappings: { + get_history: { + endpoint: "get_history", + params: ["include_activity", "length"], + }, + }, +}; + +export default widget; diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js index 7ed98bfb9..754ee6cb4 100644 --- a/src/widgets/widgets.js +++ b/src/widgets/widgets.js @@ -97,6 +97,7 @@ import strelaysrv from "./strelaysrv/widget"; import tailscale from "./tailscale/widget"; import tandoor from "./tandoor/widget"; import tautulli from "./tautulli/widget"; +import tautullihistory from "./tautullihistory/widget"; import tdarr from "./tdarr/widget"; import traefik from "./traefik/widget"; import transmission from "./transmission/widget"; @@ -215,6 +216,7 @@ const widgets = { tailscale, tandoor, tautulli, + tautullihistory, tdarr, traefik, transmission,