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:
Bothari
2026-02-09 19:35:54 -08:00
committed by GitHub
parent f4f54cea60
commit 84231a1754
11 changed files with 736 additions and 2 deletions

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"pangolin",
"tailscale",
"tandoor",
"tracearr",
"pterodactyl",
"vikunja",
"firefly",

View File

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

View 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}
/>
);
}

View 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();
});
});

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

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

View File

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