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
|
||||||
|
```
|
@ -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;
|
||||||
|
}
|
@ -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;
|
Loading…
Reference in new issue