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 &&
}
+
+
+
+ {!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,