mirror of
https://github.com/gethomepage/homepage.git
synced 2026-03-31 07:12:17 -07:00
Feature: add Tracearr widget for displaying active Plex streams (#6306)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
21
docs/widgets/services/tracearr.md
Normal file
21
docs/widgets/services/tracearr.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: Tracearr
|
||||
description: Tracearr Widget Configuration
|
||||
---
|
||||
|
||||
Learn more about [Tracearr](https://www.tracearr.com/).
|
||||
|
||||
Provides detailed information about currently active streams across multiple servers.
|
||||
|
||||
Allowed fields (for summary view): `["streams", "transcodes", "directplay", "bitrate"]`.
|
||||
|
||||
```yaml
|
||||
widget:
|
||||
type: tracearr
|
||||
url: http://tracearr.host.or.ip:3000
|
||||
key: apikeyapikeyapikeyapikeyapikey
|
||||
view: both # optional, "summary", "details", or "both", defaults to "details"
|
||||
enableUser: true # optional, defaults to false
|
||||
showEpisodeNumber: true # optional, defaults to false
|
||||
expandOneStreamToTwoRows: false # optional, defaults to true
|
||||
```
|
||||
@@ -184,6 +184,13 @@
|
||||
"no_active": "No Active Streams",
|
||||
"plex_connection_error": "Check Plex Connection"
|
||||
},
|
||||
"tracearr": {
|
||||
"no_active": "No Active Streams",
|
||||
"streams": "Streams",
|
||||
"transcodes": "Transcodes",
|
||||
"directplay": "Direct Play",
|
||||
"bitrate": "Bitrate"
|
||||
},
|
||||
"omada": {
|
||||
"connectedAp": "Connected APs",
|
||||
"activeUser": "Active devices",
|
||||
|
||||
@@ -313,7 +313,7 @@ export function cleanServiceGroups(groups) {
|
||||
enableNowPlaying,
|
||||
enableMediaControl,
|
||||
|
||||
// emby, jellyfin, tautulli
|
||||
// emby, jellyfin, tautulli, tracearr
|
||||
enableUser,
|
||||
expandOneStreamToTwoRows,
|
||||
showEpisodeNumber,
|
||||
@@ -542,12 +542,15 @@ export function cleanServiceGroups(groups) {
|
||||
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
|
||||
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
|
||||
}
|
||||
if (["emby", "jellyfin", "tautulli"].includes(type)) {
|
||||
if (["emby", "jellyfin", "tautulli", "tracearr"].includes(type)) {
|
||||
if (expandOneStreamToTwoRows !== undefined)
|
||||
widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
|
||||
if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
|
||||
if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
|
||||
}
|
||||
if (type === "tracearr") {
|
||||
if (view !== undefined) widget.view = view;
|
||||
}
|
||||
if (["sonarr", "radarr"].includes(type)) {
|
||||
if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
|
||||
}
|
||||
|
||||
@@ -312,6 +312,13 @@ describe("utils/config/service-helpers", () => {
|
||||
{ type: "healthchecks", uuid: "u" },
|
||||
{ type: "speedtest", bitratePrecision: "3", version: "1" },
|
||||
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
|
||||
{
|
||||
type: "tracearr",
|
||||
expandOneStreamToTwoRows: "true",
|
||||
showEpisodeNumber: "true",
|
||||
enableUser: "true",
|
||||
view: "both",
|
||||
},
|
||||
{ type: "wgeasy", threshold: "10", version: "1" },
|
||||
{ type: "technitium", range: "24h" },
|
||||
{ type: "lubelogger", vehicleID: "12" },
|
||||
@@ -350,6 +357,14 @@ describe("utils/config/service-helpers", () => {
|
||||
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
|
||||
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
|
||||
);
|
||||
expect(widgets.find((w) => w.type === "tracearr")).toEqual(
|
||||
expect.objectContaining({
|
||||
expandOneStreamToTwoRows: true,
|
||||
showEpisodeNumber: true,
|
||||
enableUser: true,
|
||||
view: "both",
|
||||
}),
|
||||
);
|
||||
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
|
||||
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
|
||||
});
|
||||
|
||||
@@ -64,6 +64,7 @@ export default async function credentialedProxyHandler(req, res, map) {
|
||||
"pangolin",
|
||||
"tailscale",
|
||||
"tandoor",
|
||||
"tracearr",
|
||||
"pterodactyl",
|
||||
"vikunja",
|
||||
"firefly",
|
||||
|
||||
@@ -138,6 +138,7 @@ const components = {
|
||||
tautulli: dynamic(() => import("./tautulli/component")),
|
||||
technitium: dynamic(() => import("./technitium/component")),
|
||||
tdarr: dynamic(() => import("./tdarr/component")),
|
||||
tracearr: dynamic(() => import("./tracearr/component")),
|
||||
traefik: dynamic(() => import("./traefik/component")),
|
||||
transmission: dynamic(() => import("./transmission/component")),
|
||||
trilium: dynamic(() => import("./trilium/component")),
|
||||
|
||||
268
src/widgets/tracearr/component.jsx
Normal file
268
src/widgets/tracearr/component.jsx
Normal file
@@ -0,0 +1,268 @@
|
||||
/* eslint-disable camelcase */
|
||||
import Block from "components/services/widget/block";
|
||||
import Container from "components/services/widget/container";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { BsCpu, BsFillCpuFill, BsFillPlayFill, BsPauseFill } from "react-icons/bs";
|
||||
import { MdOutlineSmartDisplay, MdSmartDisplay } from "react-icons/md";
|
||||
|
||||
import useWidgetAPI from "utils/proxy/use-widget-api";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function millisecondsToString(milliseconds) {
|
||||
const { hours, minutes, seconds } = millisecondsToTime(milliseconds);
|
||||
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 generateStreamTitle(session, enableUser, showEpisodeNumber) {
|
||||
let stream_title = "";
|
||||
const { mediaType, mediaTitle, showTitle, seasonNumber, episodeNumber, username } = session;
|
||||
|
||||
if (mediaType === "episode" && showEpisodeNumber) {
|
||||
const season_str = `S${seasonNumber.toString().padStart(2, "0")}`;
|
||||
const episode_str = `E${episodeNumber.toString().padStart(2, "0")}`;
|
||||
stream_title = `${showTitle}: ${season_str} · ${episode_str} - ${mediaTitle}`;
|
||||
} else if (mediaType === "episode") {
|
||||
stream_title = `${showTitle} - ${mediaTitle}`;
|
||||
} else {
|
||||
stream_title = mediaTitle;
|
||||
}
|
||||
|
||||
return enableUser ? `${stream_title} (${username})` : stream_title;
|
||||
}
|
||||
|
||||
function SingleSessionEntry({ session, enableUser, showEpisodeNumber }) {
|
||||
const { durationMs, progressMs, state, videoDecision, audioDecision } = session;
|
||||
const progress_percent = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;
|
||||
|
||||
const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);
|
||||
|
||||
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">
|
||||
<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" title={stream_title}>
|
||||
{stream_title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center text-xs flex justify-end mr-1.5 pl-1">
|
||||
{videoDecision === "directplay" && audioDecision === "directplay" && (
|
||||
<MdSmartDisplay className="opacity-50" />
|
||||
)}
|
||||
{videoDecision === "copy" && audioDecision === "copy" && <MdOutlineSmartDisplay className="opacity-50" />}
|
||||
{videoDecision !== "copy" &&
|
||||
videoDecision !== "directplay" &&
|
||||
(audioDecision !== "copy" || audioDecision !== "directplay") && <BsFillCpuFill className="opacity-50" />}
|
||||
{(videoDecision === "copy" || videoDecision === "directplay") &&
|
||||
audioDecision !== "copy" &&
|
||||
audioDecision !== "directplay" && <BsCpu className="opacity-50" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div
|
||||
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
|
||||
style={{
|
||||
width: `${progress_percent}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs z-10 self-center ml-1">
|
||||
{state === "paused" && (
|
||||
<BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
|
||||
)}
|
||||
{state !== "paused" && (
|
||||
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
|
||||
)}
|
||||
</div>
|
||||
<div className="grow " />
|
||||
<div className="self-center text-xs flex justify-end mr-2 z-10">
|
||||
{millisecondsToString(progressMs)}
|
||||
<span className="mx-0.5 text-[8px]">/</span>
|
||||
{millisecondsToString(durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionEntry({ session, enableUser, showEpisodeNumber }) {
|
||||
const { durationMs, progressMs, state, videoDecision, audioDecision } = session;
|
||||
const progress_percent = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;
|
||||
|
||||
const stream_title = generateStreamTitle(session, enableUser, showEpisodeNumber);
|
||||
|
||||
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">
|
||||
<div
|
||||
className="absolute h-5 rounded-md bg-theme-200 dark:bg-theme-900/40 z-0"
|
||||
style={{
|
||||
width: `${progress_percent}%`,
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs z-10 self-center ml-1">
|
||||
{state === "paused" && (
|
||||
<BsPauseFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
|
||||
)}
|
||||
{state !== "paused" && (
|
||||
<BsFillPlayFill className="inline-block w-4 h-4 cursor-pointer -mt-[1px] mr-1 opacity-80" />
|
||||
)}
|
||||
</div>
|
||||
<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" title={stream_title}>
|
||||
{stream_title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center text-xs flex justify-end mr-1.5 pl-1 z-10">
|
||||
{videoDecision === "directplay" && audioDecision === "directplay" && <MdSmartDisplay className="opacity-50" />}
|
||||
{videoDecision === "copy" && audioDecision === "copy" && <MdOutlineSmartDisplay className="opacity-50" />}
|
||||
{videoDecision !== "copy" &&
|
||||
videoDecision !== "directplay" &&
|
||||
(audioDecision !== "copy" || audioDecision !== "directplay") && <BsFillCpuFill className="opacity-50" />}
|
||||
{(videoDecision === "copy" || videoDecision === "directplay") &&
|
||||
audioDecision !== "copy" &&
|
||||
audioDecision !== "directplay" && <BsCpu className="opacity-50" />}
|
||||
</div>
|
||||
<div className="self-center text-xs flex justify-end mr-2 z-10">{millisecondsToString(progressMs)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryView({ service, summary, t }) {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="tracearr.streams" value={t("common.number", { value: summary.total })} />
|
||||
<Block label="tracearr.transcodes" value={t("common.number", { value: summary.transcodes })} />
|
||||
<Block label="tracearr.directplay" value={t("common.number", { value: summary.directPlays })} />
|
||||
<Block label="tracearr.bitrate" value={summary.totalBitrate} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsView({ playing, enableUser, showEpisodeNumber, expandOneStreamToTwoRows, t }) {
|
||||
if (playing.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col pb-1 mx-1">
|
||||
<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("tracearr.no_active")}</span>
|
||||
</div>
|
||||
{expandOneStreamToTwoRows && (
|
||||
<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]">-</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expandOneStreamToTwoRows && playing.length === 1) {
|
||||
const session = playing[0];
|
||||
return (
|
||||
<div className="flex flex-col pb-1 mx-1">
|
||||
<SingleSessionEntry session={session} enableUser={enableUser} showEpisodeNumber={showEpisodeNumber} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col pb-1 mx-1">
|
||||
{playing.map((session) => (
|
||||
<SessionEntry
|
||||
key={session.id}
|
||||
session={session}
|
||||
enableUser={enableUser}
|
||||
showEpisodeNumber={showEpisodeNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Component({ service }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { widget } = service;
|
||||
|
||||
const { data: activityData, error: activityError } = useWidgetAPI(widget, "streams", {
|
||||
refreshInterval: 5000,
|
||||
});
|
||||
|
||||
const enableUser = !!service.widget?.enableUser;
|
||||
const expandOneStreamToTwoRows = service.widget?.expandOneStreamToTwoRows !== false;
|
||||
const showEpisodeNumber = !!service.widget?.showEpisodeNumber;
|
||||
const view = service.widget?.view ?? "details";
|
||||
|
||||
if (activityError) {
|
||||
return <Container service={service} error={activityError} />;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (!activityData || !activityData.data) {
|
||||
if (view === "summary") {
|
||||
return (
|
||||
<Container service={service}>
|
||||
<Block label="tracearr.streams" />
|
||||
<Block label="tracearr.transcodes" />
|
||||
<Block label="tracearr.directplay" />
|
||||
<Block label="tracearr.bitrate" />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col pb-1 mx-1">
|
||||
<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]">-</span>
|
||||
</div>
|
||||
{expandOneStreamToTwoRows && (
|
||||
<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]">-</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const playing = activityData.data.sort((a, b) => a.progressMs - b.progressMs);
|
||||
const { summary } = activityData;
|
||||
|
||||
if (view === "summary") {
|
||||
return <SummaryView service={service} summary={summary} t={t} />;
|
||||
}
|
||||
|
||||
if (view === "both") {
|
||||
return (
|
||||
<>
|
||||
<SummaryView service={service} summary={summary} t={t} />
|
||||
<DetailsView
|
||||
playing={playing}
|
||||
enableUser={enableUser}
|
||||
showEpisodeNumber={showEpisodeNumber}
|
||||
expandOneStreamToTwoRows={expandOneStreamToTwoRows}
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Default: details view
|
||||
return (
|
||||
<DetailsView
|
||||
playing={playing}
|
||||
enableUser={enableUser}
|
||||
showEpisodeNumber={showEpisodeNumber}
|
||||
expandOneStreamToTwoRows={expandOneStreamToTwoRows}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
391
src/widgets/tracearr/component.test.jsx
Normal file
391
src/widgets/tracearr/component.test.jsx
Normal file
@@ -0,0 +1,391 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("react-icons/bs", () => ({
|
||||
BsCpu: (props) => <svg data-testid="BsCpu" {...props} />,
|
||||
BsFillCpuFill: (props) => <svg data-testid="BsFillCpuFill" {...props} />,
|
||||
BsFillPlayFill: (props) => <svg data-testid="BsFillPlayFill" {...props} />,
|
||||
BsPauseFill: (props) => <svg data-testid="BsPauseFill" {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("react-icons/md", () => ({
|
||||
MdOutlineSmartDisplay: (props) => <svg data-testid="MdOutlineSmartDisplay" {...props} />,
|
||||
MdSmartDisplay: (props) => <svg data-testid="MdSmartDisplay" {...props} />,
|
||||
}));
|
||||
|
||||
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
|
||||
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
|
||||
|
||||
import Component from "./component";
|
||||
|
||||
describe("widgets/tracearr/component", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders placeholder rows while loading", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getAllByText("-").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders placeholder blocks while loading in summary view", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: { data: null }, error: undefined });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr", view: "summary" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("tracearr.streams")).toBeInTheDocument();
|
||||
expect(screen.getByText("tracearr.transcodes")).toBeInTheDocument();
|
||||
expect(screen.getByText("tracearr.directplay")).toBeInTheDocument();
|
||||
expect(screen.getByText("tracearr.bitrate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders errors from the widget API", () => {
|
||||
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "boom" } });
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText(/widget\.api_error\s+widget\.information/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no-active message when there are no streams", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: { data: [], summary: { total: 0, transcodes: 0, directPlays: 0, totalBitrate: "0 Mbps" } },
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("tracearr.no_active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an expanded two-row entry when a single stream is playing", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000, // 2 hours
|
||||
progressMs: 2700000, // 45 minutes in
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
expect(screen.getByText(/45:00/)).toBeInTheDocument(); // 45 minutes in
|
||||
expect(screen.getByText(/02:00:00/)).toBeInTheDocument(); // 2 hour duration
|
||||
});
|
||||
|
||||
it("uses 0% progress when duration is 0 in expanded view", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Short Clip",
|
||||
mediaType: "movie",
|
||||
durationMs: 0,
|
||||
progressMs: 5000,
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "1 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
const bars = container.querySelectorAll('div[style*="width"]');
|
||||
expect(bars.length).toBeGreaterThan(0);
|
||||
expect(bars[0]).toHaveStyle({ width: "0%" });
|
||||
});
|
||||
|
||||
it("renders episode title with season/episode and username when configured", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "2",
|
||||
mediaTitle: "Ozymandias",
|
||||
showTitle: "Breaking Bad",
|
||||
mediaType: "episode",
|
||||
seasonNumber: 5,
|
||||
episodeNumber: 14,
|
||||
durationMs: 2700000,
|
||||
progressMs: 1200000,
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
username: "Walter",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "10 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<Component
|
||||
service={{
|
||||
widget: { type: "tracearr", enableUser: true, showEpisodeNumber: true, expandOneStreamToTwoRows: false },
|
||||
}}
|
||||
/>,
|
||||
{ settings: { hideErrors: false } },
|
||||
);
|
||||
|
||||
expect(screen.getByText("Breaking Bad: S05 · E14 - Ozymandias (Walter)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders multiple streams including movie and tv episode", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000, // 2 hours
|
||||
progressMs: 2700000, // 45 minutes in
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
mediaTitle: "Ozymandias",
|
||||
showTitle: "Breaking Bad",
|
||||
mediaType: "episode",
|
||||
seasonNumber: 5,
|
||||
episodeNumber: 14,
|
||||
durationMs: 2700000, // 45 minutes
|
||||
progressMs: 1200000, // 20 minutes in
|
||||
state: "playing",
|
||||
videoDecision: "transcode",
|
||||
audioDecision: "directplay",
|
||||
username: "Walter",
|
||||
},
|
||||
],
|
||||
summary: { total: 2, transcodes: 1, directPlays: 1, totalBitrate: "35 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
expect(screen.getByText("Breaking Bad - Ozymandias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["copy/copy shows copy icon", { videoDecision: "copy", audioDecision: "copy" }, "MdOutlineSmartDisplay"],
|
||||
["transcode shows cpu fill icon", { videoDecision: "transcode", audioDecision: "directplay" }, "BsFillCpuFill"],
|
||||
["transcode+copy shows cpu fill icon", { videoDecision: "transcode", audioDecision: "copy" }, "BsFillCpuFill"],
|
||||
["mixed transcode shows cpu icon", { videoDecision: "directplay", audioDecision: "transcode" }, "BsCpu"],
|
||||
])("renders transcoding indicators in expanded view: %s", (_label, decisions, expectedIcon) => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000,
|
||||
progressMs: 2700000,
|
||||
state: "playing",
|
||||
...decisions,
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByTestId(expectedIcon)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pause icon when a stream is paused in expanded view", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000,
|
||||
progressMs: 2700000,
|
||||
state: "paused",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr" } }} />, { settings: { hideErrors: false } });
|
||||
|
||||
expect(screen.getByTestId("BsPauseFill")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["copy/copy shows copy icon", { videoDecision: "copy", audioDecision: "copy" }, "MdOutlineSmartDisplay"],
|
||||
["transcode shows cpu fill icon", { videoDecision: "transcode", audioDecision: "directplay" }, "BsFillCpuFill"],
|
||||
["transcode+copy shows cpu fill icon", { videoDecision: "transcode", audioDecision: "copy" }, "BsFillCpuFill"],
|
||||
["mixed transcode shows cpu icon", { videoDecision: "directplay", audioDecision: "transcode" }, "BsCpu"],
|
||||
])("renders transcoding indicators in single-row view: %s", (_label, decisions, expectedIcon) => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000,
|
||||
progressMs: 2700000,
|
||||
state: "playing",
|
||||
...decisions,
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr", expandOneStreamToTwoRows: false } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(expectedIcon)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a pause icon when a stream is paused in single-row view", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000,
|
||||
progressMs: 2700000,
|
||||
state: "paused",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr", expandOneStreamToTwoRows: false } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("BsPauseFill")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses 0% progress when duration is 0 in single-row view", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Short Clip",
|
||||
mediaType: "movie",
|
||||
durationMs: 0,
|
||||
progressMs: 5000,
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "1 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<Component service={{ widget: { type: "tracearr", expandOneStreamToTwoRows: false } }} />,
|
||||
{
|
||||
settings: { hideErrors: false },
|
||||
},
|
||||
);
|
||||
|
||||
const bars = container.querySelectorAll('div[style*="width"]');
|
||||
expect(bars.length).toBeGreaterThan(0);
|
||||
expect(bars[0]).toHaveStyle({ width: "0%" });
|
||||
});
|
||||
|
||||
it("renders summary view when view option is set to summary", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [],
|
||||
summary: { total: 5, transcodes: 2, directPlays: 3, totalBitrate: "45 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr", view: "summary" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("tracearr.streams")).toBeInTheDocument();
|
||||
expect(screen.getByText("tracearr.bitrate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both summary and details when view option is set to both", () => {
|
||||
useWidgetAPI.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: "1",
|
||||
mediaTitle: "Inception",
|
||||
mediaType: "movie",
|
||||
durationMs: 7200000,
|
||||
progressMs: 2700000,
|
||||
state: "playing",
|
||||
videoDecision: "directplay",
|
||||
audioDecision: "directplay",
|
||||
},
|
||||
],
|
||||
summary: { total: 1, transcodes: 0, directPlays: 1, totalBitrate: "20 Mbps" },
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
renderWithProviders(<Component service={{ widget: { type: "tracearr", view: "both" } }} />, {
|
||||
settings: { hideErrors: false },
|
||||
});
|
||||
|
||||
expect(screen.getByText("tracearr.streams")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inception")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
14
src/widgets/tracearr/widget.js
Normal file
14
src/widgets/tracearr/widget.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
|
||||
|
||||
const widget = {
|
||||
api: "{url}/api/v1/public/{endpoint}",
|
||||
proxyHandler: credentialedProxyHandler,
|
||||
|
||||
mappings: {
|
||||
streams: {
|
||||
endpoint: "streams",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default widget;
|
||||
11
src/widgets/tracearr/widget.test.js
Normal file
11
src/widgets/tracearr/widget.test.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import { expectWidgetConfigShape } from "test-utils/widget-config";
|
||||
|
||||
import widget from "./widget";
|
||||
|
||||
describe("tracearr widget config", () => {
|
||||
it("exports a valid widget config", () => {
|
||||
expectWidgetConfigShape(widget);
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,7 @@ import tandoor from "./tandoor/widget";
|
||||
import tautulli from "./tautulli/widget";
|
||||
import tdarr from "./tdarr/widget";
|
||||
import technitium from "./technitium/widget";
|
||||
import tracearr from "./tracearr/widget";
|
||||
import traefik from "./traefik/widget";
|
||||
import transmission from "./transmission/widget";
|
||||
import trilium from "./trilium/widget";
|
||||
@@ -285,6 +286,7 @@ const widgets = {
|
||||
tautulli,
|
||||
technitium,
|
||||
tdarr,
|
||||
tracearr,
|
||||
traefik,
|
||||
transmission,
|
||||
trilium,
|
||||
|
||||
Reference in New Issue
Block a user