From 84231a1754620589573fd4fa6f5f9d774efc33bd Mon Sep 17 00:00:00 2001
From: Bothari <18599875+Bothari@users.noreply.github.com>
Date: Mon, 9 Feb 2026 19:35:54 -0800
Subject: [PATCH] Feature: add Tracearr widget for displaying active Plex
streams (#6306)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
---
docs/widgets/services/tracearr.md | 21 ++
public/locales/en/common.json | 7 +
src/utils/config/service-helpers.js | 7 +-
src/utils/config/service-helpers.test.js | 15 +
src/utils/proxy/handlers/credentialed.js | 1 +
src/widgets/components.js | 1 +
src/widgets/tracearr/component.jsx | 268 ++++++++++++++++
src/widgets/tracearr/component.test.jsx | 391 +++++++++++++++++++++++
src/widgets/tracearr/widget.js | 14 +
src/widgets/tracearr/widget.test.js | 11 +
src/widgets/widgets.js | 2 +
11 files changed, 736 insertions(+), 2 deletions(-)
create mode 100644 docs/widgets/services/tracearr.md
create mode 100644 src/widgets/tracearr/component.jsx
create mode 100644 src/widgets/tracearr/component.test.jsx
create mode 100644 src/widgets/tracearr/widget.js
create mode 100644 src/widgets/tracearr/widget.test.js
diff --git a/docs/widgets/services/tracearr.md b/docs/widgets/services/tracearr.md
new file mode 100644
index 000000000..c53f8aca1
--- /dev/null
+++ b/docs/widgets/services/tracearr.md
@@ -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
+```
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 08fed5656..210eb538c 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -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",
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 0a2940a4f..52f8dcb05 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -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);
}
diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js
index 452b70f0b..0c6cf44ab 100644
--- a/src/utils/config/service-helpers.test.js
+++ b/src/utils/config/service-helpers.test.js
@@ -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 }));
});
diff --git a/src/utils/proxy/handlers/credentialed.js b/src/utils/proxy/handlers/credentialed.js
index 4a767b55c..27f02f3e5 100644
--- a/src/utils/proxy/handlers/credentialed.js
+++ b/src/utils/proxy/handlers/credentialed.js
@@ -64,6 +64,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"pangolin",
"tailscale",
"tandoor",
+ "tracearr",
"pterodactyl",
"vikunja",
"firefly",
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 61585f5f6..c0c9c9fc6 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -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")),
diff --git a/src/widgets/tracearr/component.jsx b/src/widgets/tracearr/component.jsx
new file mode 100644
index 000000000..41a52b66c
--- /dev/null
+++ b/src/widgets/tracearr/component.jsx
@@ -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 (
+ <>
+
+
+
+ {videoDecision === "directplay" && audioDecision === "directplay" && (
+
+ )}
+ {videoDecision === "copy" && audioDecision === "copy" && }
+ {videoDecision !== "copy" &&
+ videoDecision !== "directplay" &&
+ (audioDecision !== "copy" || audioDecision !== "directplay") && }
+ {(videoDecision === "copy" || videoDecision === "directplay") &&
+ audioDecision !== "copy" &&
+ audioDecision !== "directplay" && }
+
+
+
+
+
+
+ {state === "paused" && (
+
+ )}
+ {state !== "paused" && (
+
+ )}
+
+
+
+ {millisecondsToString(progressMs)}
+ /
+ {millisecondsToString(durationMs)}
+
+
+ >
+ );
+}
+
+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 (
+
+
+
+ {state === "paused" && (
+
+ )}
+ {state !== "paused" && (
+
+ )}
+
+
+
+ {videoDecision === "directplay" && audioDecision === "directplay" && }
+ {videoDecision === "copy" && audioDecision === "copy" && }
+ {videoDecision !== "copy" &&
+ videoDecision !== "directplay" &&
+ (audioDecision !== "copy" || audioDecision !== "directplay") && }
+ {(videoDecision === "copy" || videoDecision === "directplay") &&
+ audioDecision !== "copy" &&
+ audioDecision !== "directplay" && }
+
+
{millisecondsToString(progressMs)}
+
+ );
+}
+
+function SummaryView({ service, summary, t }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+function DetailsView({ playing, enableUser, showEpisodeNumber, expandOneStreamToTwoRows, t }) {
+ if (playing.length === 0) {
+ return (
+
+
+ {t("tracearr.no_active")}
+
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
+
+ );
+ }
+
+ if (expandOneStreamToTwoRows && playing.length === 1) {
+ const session = playing[0];
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {playing.map((session) => (
+
+ ))}
+
+ );
+}
+
+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 ;
+ }
+
+ // Loading state
+ if (!activityData || !activityData.data) {
+ if (view === "summary") {
+ return (
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+ -
+
+ {expandOneStreamToTwoRows && (
+
+ -
+
+ )}
+
+ );
+ }
+
+ const playing = activityData.data.sort((a, b) => a.progressMs - b.progressMs);
+ const { summary } = activityData;
+
+ if (view === "summary") {
+ return ;
+ }
+
+ if (view === "both") {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ // Default: details view
+ return (
+
+ );
+}
diff --git a/src/widgets/tracearr/component.test.jsx b/src/widgets/tracearr/component.test.jsx
new file mode 100644
index 000000000..c9b54e950
--- /dev/null
+++ b/src/widgets/tracearr/component.test.jsx
@@ -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) => ,
+ BsFillCpuFill: (props) => ,
+ BsFillPlayFill: (props) => ,
+ BsPauseFill: (props) => ,
+}));
+
+vi.mock("react-icons/md", () => ({
+ MdOutlineSmartDisplay: (props) => ,
+ 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(, { 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(, {
+ 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(, { 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(, { 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(, { 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(, {
+ 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(
+ ,
+ { 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(, { 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(, { 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(, { 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(, {
+ 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(, {
+ 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(
+ ,
+ {
+ 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(, {
+ 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(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("tracearr.streams")).toBeInTheDocument();
+ expect(screen.getByText("Inception")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/tracearr/widget.js b/src/widgets/tracearr/widget.js
new file mode 100644
index 000000000..5e52a1513
--- /dev/null
+++ b/src/widgets/tracearr/widget.js
@@ -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;
diff --git a/src/widgets/tracearr/widget.test.js b/src/widgets/tracearr/widget.test.js
new file mode 100644
index 000000000..3980a41a4
--- /dev/null
+++ b/src/widgets/tracearr/widget.test.js
@@ -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);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 5142ee23c..cef020e51 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -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,