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 ( + <> +
+
+
+ {stream_title} +
+
+
+ {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" && ( + + )} +
+
+
+ {stream_title} +
+
+
+ {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,