Add a Tautulli History Widget

pull/3343/head
brikim 2 weeks ago
parent 79e3eb9c90
commit 65c2aa1b35

@ -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
```

@ -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

@ -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",

@ -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);
}

@ -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 (
<div className="z-10 self-center ml-1 mr-1 h-3.5">
<div className="w-4 text-sm z-10 overflow-hidden justify-start">
{(platform === "android" || platform === "chromecast") && <BsAndroid2 className={opacity} />}
{(platform === "apple tv" || platform === "tvos" || platform === "ios" ||
platform === "ipad" || platform === "iphone" || platform === "osx" ||
platform === "macos") && <BsApple className={opacity} />}
{platform === "chrome" && <BsBrowserChrome className={opacity} />}
{platform === "firefox" && <BsBrowserFirefox className={opacity} />}
{platform === "linux" && <SiLinux className={opacity} />}
{(platform === "microsoft edge" || platform === "internet explorer") && <BsBrowserEdge className={opacity} />}
{(platform === "netcast" || platform === "webos") && <SiLg className={opacity} />}
{(platform === "opera" || platform === "vizio") && <SiOperagx className={opacity} />}
{platform === "playstation" && <BsPlaystation className={opacity} />}
{(platform === "plex home theater" || platform === "plex media player" ||
platform === "plexamp" || platform === "plextogether") && <SiPlex className={opacity} />}
{platform === "roku" && <SiRoku className={opacity} />}
{platform === "safari" && <SiSafari className={opacity} />}
{(platform === "samsung" || platform === "tizen") && <SiSamsung className={opacity} />}
{platform === "wiiu" && <SiWii className={opacity} />}
{(platform === "windows" || platform === "windows phone") && <BsWindows className={opacity} />}
{platform === "xbox" && <BsXbox className={opacity} />}
</div>
</div>
);
}

@ -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 (
<div className="self-center text-base flex z-10">
{videoDecision === "direct play" && audioDecision === "direct play" && (
<MdOutlineSmartDisplay className={opacity} />
)}
{videoDecision === "copy" && audioDecision === "copy" && <PiCopy className={opacity} />}
{videoDecision !== "copy" && videoDecision !== "direct play" && <PiCpuFill className={opacity} />}
{(videoDecision === "copy" || videoDecision === "direct play") &&
(audioDecision !== "copy" && audioDecision !== "direct play") && <PiCpu className={opacity} />}
</div>
);
}

@ -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;
}

@ -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")),

@ -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 }) {
<div className="text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden">
{full_title}
{enableUser && ` (${username})`}
{enableUser && ` (${friendly_name})`}
</div>
</div>
<div className="self-center text-xs flex justify-end mr-1.5 pl-1">
@ -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 (
<div className="text-theme-700 dark:text-theme-200 relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1 flex">
@ -100,7 +100,7 @@ function SessionEntry({ session, enableUser }) {
<div className="text-xs z-10 self-center ml-2 relative w-full h-4 grow mr-2">
<div className="absolute w-full whitespace-nowrap text-ellipsis overflow-hidden">
{full_title}
{enableUser && ` (${username})`}
{enableUser && ` (${friendly_name})`}
</div>
</div>
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10">

@ -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 (
<div className="flex flex-row text-theme-700 dark:text-theme-200 items-center text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<div
className="flex"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
key={key}>
<div className="text-xs z-10 self-center ml-1 mr-1 h-4 grow">
<div className="w-10 z-10 self-center overflow-hidden justify-start">{stoppedDate.setLocale(i18n.language).toLocaleString({ month: "short", day: "numeric" })}</div>
</div>
{platform && <PlatformIcon platform={platform.toLowerCase()} opacity="opacity-70"/>}
<div className="text-xs z-10 self-center ml-1.5 h-4 grow mr-1">
<div className="w-20 z-10 self-center overflow-hidden justify-start">{friendly_name}</div>
</div>
</div>
<div className="z-10 self-center ml-1 relative w-full h-4 grow mr-1">
{!hover && friendly_name !== "unknown" &&
<div
className="absolute text-xs w-full whitespace-nowrap text-ellipsis overflow-hidden"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
key={key}>{full_title}</div>
}
{hover && friendly_name !== "unknown" &&
<div
className="absolute text-xs w-full flex whitespace-nowrap text-ellipsis overflow-hidden"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
key={key}>
<div className="w-5 self-center justify-start">
<PlayStatusIcon videoDecision={transcode_decision} audioDecision={transcode_decision} opacity="opacity-70"/>
</div>
<div className="self-center ml-1 whitespace-nowrap text-ellipsis overflow-hidden">{player}</div>
<div className="grow "/>
<div className="self-center text-xs justify-end mr-0.5 pl-1">{play_duration && MillisecondsToString(play_duration * 1000)}</div>
<div className="self-center flex justify-end mr-0.5 pl-0.5">
<div className="text-base"><BiCircle className="opacity-40"/></div>
<div className="absolute self-center">
{watched_status === 0.25 &&
<div className="text-xs mr-0.5"><BiSolidCircleQuarter className="opacity-60"/></div>}
{watched_status === 0.5 &&
<div className="text-xs mr-0.5"><BiSolidCircleHalf className="opacity-60"/></div>}
{watched_status === 0.75 &&
<div className="text-xs mr-0.5"><BiSolidCircleThreeQuarter className="opacity-60"/></div>}
{watched_status === 1 &&
<div className="text-xs mr-0.5"><BiSolidCircle className="opacity-60"/></div>}
</div>
</div>
</div>
}
</div>
</div>
);
}
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 <Container service={service} error={historyError ?? { message: t("tautulli.plex_connection_error") }} />;
}
if (!historyData || historyData.response.data.data.length === 0) {
return (
<div className={classNames("flex flex-col", (!historyData || historyData.response.data.data.length === 0) && "animate-pulse")}>
<div className="text-theme-700 dark:text-theme-200 text-xs relative h-5 w-full rounded-md bg-theme-200/50 dark:bg-theme-900/20 mt-1">
<span className="absolute left-2 text-xs mt-[2px]">{t("tautullihistory.no_history")}</span>
</div>
</div>
);
}
return (
<div className="flex flex-col pb-1 mx-1">
{ historyData.response.data.data.map((record) => (
<RecordEntry
key={`record-entry-${record.full_title}-${record.user}-${record.stopped}`}
record={record}
/>
))}
</div>
);
}

@ -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;

@ -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,

Loading…
Cancel
Save