@@ -27,24 +42,15 @@ export default function Component({ service }) {
);
}
- let rateDl = 0;
- let rateUl = 0;
- let completed = 0;
- const leechTorrents = [];
+ const rateDl = Number(transferData?.dl_info_speed ?? 0);
+ const rateUl = Number(transferData?.up_info_speed ?? 0);
+ const totalCount = Number(totalCountData?.all ?? totalCountData?.count ?? totalCountData ?? 0);
+ const completedCount = Number(
+ completedCountData?.completed ?? completedCountData?.count ?? completedCountData?.all ?? completedCountData ?? 0,
+ );
+ const leech = Math.max(0, totalCount - completedCount);
- for (let i = 0; i < torrentData.length; i += 1) {
- const torrent = torrentData[i];
- rateDl += torrent.dlspeed;
- rateUl += torrent.upspeed;
- if (torrent.progress === 1) {
- completed += 1;
- }
- if (torrent.state.includes("DL") || torrent.state === "downloading") {
- leechTorrents.push(torrent);
- }
- }
-
- const leech = torrentData.length - completed;
+ const leechTorrents = Array.isArray(leechTorrentData) ? [...leechTorrentData] : [];
const statePriority = [
"downloading",
"forcedDL",
@@ -55,7 +61,6 @@ export default function Component({ service }) {
"queuedDL",
"pausedDL",
];
-
leechTorrents.sort((firstTorrent, secondTorrent) => {
const firstStateIndex = statePriority.indexOf(firstTorrent.state);
const secondStateIndex = statePriority.indexOf(secondTorrent.state);
@@ -70,7 +75,7 @@ export default function Component({ service }) {
-
+
{widget?.enableLeechProgress &&
diff --git a/src/widgets/qbittorrent/component.test.jsx b/src/widgets/qbittorrent/component.test.jsx
index d2f46095f..df8ae51c8 100644
--- a/src/widgets/qbittorrent/component.test.jsx
+++ b/src/widgets/qbittorrent/component.test.jsx
@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
@@ -15,12 +15,6 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/qbittorrent/component", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -40,19 +34,38 @@ describe("widgets/qbittorrent/component", () => {
expect(screen.getByText("qbittorrent.upload")).toBeInTheDocument();
});
- it("computes leech/seed counts and upload/download rates, and can render leech progress entries", () => {
- useWidgetAPI.mockReturnValue({
- data: [
- { name: "A", dlspeed: 10, upspeed: 1, progress: 1, state: "uploading" },
- { name: "B", dlspeed: 5, upspeed: 2, progress: 0.5, state: "downloading", eta: 60, size: 100, amount_left: 50 },
- ],
- error: undefined,
+ it("uses lightweight endpoints for counts/rates and filtered torrents for leech progress", () => {
+ useWidgetAPI.mockImplementation((_widget, endpoint, query) => {
+ if (endpoint === "transfer") {
+ return { data: { dl_info_speed: 15, up_info_speed: 3 }, error: undefined };
+ }
+ if (endpoint === "torrentCount" && !query) {
+ return { data: 2, error: undefined };
+ }
+ if (endpoint === "torrentCount" && query?.filter === "completed") {
+ return { data: 1, error: undefined };
+ }
+ if (endpoint === "torrents" && query?.filter === "downloading") {
+ return {
+ data: [
+ {
+ name: "B",
+ progress: 0.5,
+ state: "downloading",
+ eta: 60,
+ size: 100,
+ amount_left: 50,
+ },
+ ],
+ error: undefined,
+ };
+ }
+ return { data: undefined, error: undefined };
});
const service = { widget: { type: "qbittorrent", enableLeechProgress: true } };
const { container } = renderWithProviders(, { settings: { hideErrors: false } });
- // total=2, completed=1 => leech=1
expectBlockValue(container, "qbittorrent.leech", 1);
expectBlockValue(container, "qbittorrent.seed", 1);
expectBlockValue(container, "qbittorrent.download", 15);
diff --git a/src/widgets/qbittorrent/widget.js b/src/widgets/qbittorrent/widget.js
index 182ac9d1b..9ec167faf 100644
--- a/src/widgets/qbittorrent/widget.js
+++ b/src/widgets/qbittorrent/widget.js
@@ -4,8 +4,16 @@ const widget = {
proxyHandler: qbittorrentProxyHandler,
mappings: {
+ transfer: {
+ endpoint: "transfer/info",
+ },
+ torrentCount: {
+ endpoint: "torrents/count",
+ optionalParams: ["filter"],
+ },
torrents: {
endpoint: "torrents/info",
+ optionalParams: ["filter"],
},
},
};
diff --git a/src/widgets/qnap/component.test.jsx b/src/widgets/qnap/component.test.jsx
index c08450258..c6778660d 100644
--- a/src/widgets/qnap/component.test.jsx
+++ b/src/widgets/qnap/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/qnap/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/radarr/component.test.jsx b/src/widgets/radarr/component.test.jsx
index 7637f0d3c..09a5c34b2 100644
--- a/src/widgets/radarr/component.test.jsx
+++ b/src/widgets/radarr/component.test.jsx
@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
@@ -15,12 +15,6 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/radarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/readarr/component.test.jsx b/src/widgets/readarr/component.test.jsx
index ac66fdee8..7f0c55fb4 100644
--- a/src/widgets/readarr/component.test.jsx
+++ b/src/widgets/readarr/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/readarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/romm/component.test.jsx b/src/widgets/romm/component.test.jsx
index cabe6dec9..28384f8a1 100644
--- a/src/widgets/romm/component.test.jsx
+++ b/src/widgets/romm/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/romm/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/sabnzbd/component.test.jsx b/src/widgets/sabnzbd/component.test.jsx
index a81db1399..12a278efb 100644
--- a/src/widgets/sabnzbd/component.test.jsx
+++ b/src/widgets/sabnzbd/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/sabnzbd/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/scrutiny/component.test.jsx b/src/widgets/scrutiny/component.test.jsx
index 68b0d85bd..03e85c4b9 100644
--- a/src/widgets/scrutiny/component.test.jsx
+++ b/src/widgets/scrutiny/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/scrutiny/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/seerr/component.jsx b/src/widgets/seerr/component.jsx
new file mode 100644
index 000000000..382d81213
--- /dev/null
+++ b/src/widgets/seerr/component.jsx
@@ -0,0 +1,49 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export const seerrDefaultFields = ["pending", "approved", "completed"];
+const MAX_ALLOWED_FIELDS = 4;
+
+export default function Component({ service }) {
+ const { widget } = service;
+ widget.fields = widget?.fields?.length ? widget.fields.slice(0, MAX_ALLOWED_FIELDS) : seerrDefaultFields;
+ const isIssueEnabled = widget.fields.includes("issues");
+
+ const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
+ const { data: issueData, error: issueError } = useWidgetAPI(widget, isIssueEnabled ? "issue/count" : "");
+ if (statsError || (isIssueEnabled && issueError)) {
+ return ;
+ }
+
+ if (!statsData || (isIssueEnabled && !issueData)) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ if (statsData.completed === undefined) {
+ // Newer versions added "completed", fallback to available
+ widget.fields = widget.fields.filter((field) => field !== "completed");
+ widget.fields.push("available");
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/seerr/component.test.jsx b/src/widgets/seerr/component.test.jsx
new file mode 100644
index 000000000..a1c51f5c0
--- /dev/null
+++ b/src/widgets/seerr/component.test.jsx
@@ -0,0 +1,130 @@
+// @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";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component, { seerrDefaultFields } from "./component";
+
+describe("widgets/seerr/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("defaults fields and filters to 3 blocks while loading when issues are not enabled", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
+
+ const service = { widget: { type: "seerr", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(seerrDefaultFields);
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("seerr.pending")).toBeInTheDocument();
+ expect(screen.getByText("seerr.approved")).toBeInTheDocument();
+ expect(screen.getByText("seerr.completed")).toBeInTheDocument();
+ expect(screen.queryByText("seerr.available")).toBeNull();
+ expect(screen.queryByText("seerr.processing")).toBeNull();
+ expect(screen.queryByText("seerr.issues")).toBeNull();
+ });
+
+ it("supports jellyseerr as a legacy alias to seerr", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
+
+ const service = { widget: { type: "jellyseerr", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(seerrDefaultFields);
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("seerr.pending")).toBeInTheDocument();
+ expect(screen.getByText("seerr.approved")).toBeInTheDocument();
+ expect(screen.getByText("seerr.completed")).toBeInTheDocument();
+ });
+
+ it("supports overseerr as a legacy alias with the same default fields", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
+
+ const service = { widget: { type: "overseerr", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(seerrDefaultFields);
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("seerr.pending")).toBeInTheDocument();
+ expect(screen.getByText("seerr.approved")).toBeInTheDocument();
+ expect(screen.getByText("seerr.completed")).toBeInTheDocument();
+ });
+
+ it("keeps processing as a separate optional field", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 1, processing: 2, approved: 3, available: 4 }, error: undefined })
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
+
+ const service = {
+ widget: { type: "overseerr", url: "http://x", fields: ["pending", "processing", "approved", "available"] },
+ };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("seerr.processing")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.queryByText("seerr.completed")).toBeNull();
+ });
+
+ it("renders issues when enabled (and calls the issue/count endpoint)", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3, completed: 4 }, error: undefined })
+ .mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });
+
+ const service = {
+ widget: { type: "seerr", url: "http://x", fields: ["pending", "approved", "completed", "issues"] },
+ };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("issue/count");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("1 / 2")).toBeInTheDocument();
+ expect(screen.getByText("4")).toBeInTheDocument();
+ });
+
+ it("falls back from completed to available on older Seerr responses", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })
+ .mockReturnValueOnce({ data: undefined, error: undefined });
+
+ const service = {
+ widget: { type: "seerr", url: "http://x", fields: ["pending", "approved", "completed"] },
+ };
+
+ renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(["pending", "approved", "available"]);
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.queryByText("seerr.completed")).toBeNull();
+ });
+
+ it("renders error UI when issues are enabled and issue/count errors", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })
+ .mockReturnValueOnce({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/jellyseerr/widget.js b/src/widgets/seerr/widget.js
similarity index 100%
rename from src/widgets/jellyseerr/widget.js
rename to src/widgets/seerr/widget.js
diff --git a/src/widgets/jellyseerr/widget.test.js b/src/widgets/seerr/widget.test.js
similarity index 83%
rename from src/widgets/jellyseerr/widget.test.js
rename to src/widgets/seerr/widget.test.js
index 14ffdfca9..cdcda3a8f 100644
--- a/src/widgets/jellyseerr/widget.test.js
+++ b/src/widgets/seerr/widget.test.js
@@ -4,7 +4,7 @@ import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
-describe("jellyseerr widget config", () => {
+describe("seerr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
diff --git a/src/widgets/slskd/component.test.jsx b/src/widgets/slskd/component.test.jsx
index 2888c6917..4228430d1 100644
--- a/src/widgets/slskd/component.test.jsx
+++ b/src/widgets/slskd/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/slskd/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/sonarr/component.test.jsx b/src/widgets/sonarr/component.test.jsx
index 4972cd31d..71dd84bb3 100644
--- a/src/widgets/sonarr/component.test.jsx
+++ b/src/widgets/sonarr/component.test.jsx
@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
@@ -15,12 +15,6 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/sonarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/sparkyfitness/component.jsx b/src/widgets/sparkyfitness/component.jsx
new file mode 100644
index 000000000..c9c1db42b
--- /dev/null
+++ b/src/widgets/sparkyfitness/component.jsx
@@ -0,0 +1,35 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+
+ const { widget } = service;
+ const { data, error } = useWidgetAPI(widget, "stats");
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/sparkyfitness/component.test.jsx b/src/widgets/sparkyfitness/component.test.jsx
new file mode 100644
index 000000000..b53a9a0e2
--- /dev/null
+++ b/src/widgets/sparkyfitness/component.test.jsx
@@ -0,0 +1,61 @@
+// @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";
+import { expectBlockValue } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+describe("widgets/sparkyfitness/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls the stats endpoint and renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "sparkyfitness", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(useWidgetAPI).toHaveBeenCalledWith(service.widget, "stats");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("sparkyfitness.eaten")).toBeInTheDocument();
+ expect(screen.getByText("sparkyfitness.burned")).toBeInTheDocument();
+ expect(screen.getByText("sparkyfitness.remaining")).toBeInTheDocument();
+ expect(screen.getByText("sparkyfitness.steps")).toBeInTheDocument();
+ expect(screen.getAllByText("-")).toHaveLength(4);
+ });
+
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("renders numeric values when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: { eaten: 100, burned: 200, remaining: 300, steps: 400 },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "sparkyfitness.eaten", 100);
+ expectBlockValue(container, "sparkyfitness.burned", 200);
+ expectBlockValue(container, "sparkyfitness.remaining", 300);
+ expectBlockValue(container, "sparkyfitness.steps", 400);
+ });
+});
diff --git a/src/widgets/sparkyfitness/widget.js b/src/widgets/sparkyfitness/widget.js
new file mode 100644
index 000000000..4447fc943
--- /dev/null
+++ b/src/widgets/sparkyfitness/widget.js
@@ -0,0 +1,15 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ stats: {
+ endpoint: "api/dashboard/stats",
+ validate: ["eaten", "burned", "remaining", "steps"],
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/sparkyfitness/widget.test.js b/src/widgets/sparkyfitness/widget.test.js
new file mode 100644
index 000000000..50889a3a1
--- /dev/null
+++ b/src/widgets/sparkyfitness/widget.test.js
@@ -0,0 +1,15 @@
+import { describe, expect, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("sparkyfitness widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+
+ const statsMapping = widget.mappings?.stats;
+ expect(statsMapping?.endpoint).toBe("api/dashboard/stats");
+ expect(statsMapping?.validate).toEqual(["eaten", "burned", "remaining", "steps"]);
+ });
+});
diff --git a/src/widgets/speedtest/component.test.jsx b/src/widgets/speedtest/component.test.jsx
index 87e9e83b1..54b89558e 100644
--- a/src/widgets/speedtest/component.test.jsx
+++ b/src/widgets/speedtest/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/speedtest/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/spoolman/component.test.jsx b/src/widgets/spoolman/component.test.jsx
index 1d467fe20..be5ea1d87 100644
--- a/src/widgets/spoolman/component.test.jsx
+++ b/src/widgets/spoolman/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/spoolman/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/stash/component.test.jsx b/src/widgets/stash/component.test.jsx
index 798ec6e17..201bb9e29 100644
--- a/src/widgets/stash/component.test.jsx
+++ b/src/widgets/stash/component.test.jsx
@@ -4,16 +4,10 @@ import { screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/stash/component", () => {
const originalFetch = globalThis.fetch;
diff --git a/src/widgets/strelaysrv/component.test.jsx b/src/widgets/strelaysrv/component.test.jsx
index 578a17062..933cd628d 100644
--- a/src/widgets/strelaysrv/component.test.jsx
+++ b/src/widgets/strelaysrv/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/strelaysrv/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/suwayomi/component.test.jsx b/src/widgets/suwayomi/component.test.jsx
index 3945be219..a3a779d85 100644
--- a/src/widgets/suwayomi/component.test.jsx
+++ b/src/widgets/suwayomi/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/suwayomi/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/tailscale/component.test.jsx b/src/widgets/tailscale/component.test.jsx
index d3214b06a..602d6ba1c 100644
--- a/src/widgets/tailscale/component.test.jsx
+++ b/src/widgets/tailscale/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue, findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/tailscale/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/tandoor/component.test.jsx b/src/widgets/tandoor/component.test.jsx
index e3ab09efc..40162469e 100644
--- a/src/widgets/tandoor/component.test.jsx
+++ b/src/widgets/tandoor/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/tandoor/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/tdarr/component.test.jsx b/src/widgets/tdarr/component.test.jsx
index 051a5d366..918b5c6c6 100644
--- a/src/widgets/tdarr/component.test.jsx
+++ b/src/widgets/tdarr/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/tdarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/technitium/component.test.jsx b/src/widgets/technitium/component.test.jsx
index af54b493a..7888d687d 100644
--- a/src/widgets/technitium/component.test.jsx
+++ b/src/widgets/technitium/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue, findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { technitiumDefaultFields } from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/technitium/component", () => {
beforeEach(() => {
vi.clearAllMocks();
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/traefik/component.test.jsx b/src/widgets/traefik/component.test.jsx
index 9f1c9313a..fb638612f 100644
--- a/src/widgets/traefik/component.test.jsx
+++ b/src/widgets/traefik/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/traefik/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/transmission/component.test.jsx b/src/widgets/transmission/component.test.jsx
index 95d2593e4..25b702add 100644
--- a/src/widgets/transmission/component.test.jsx
+++ b/src/widgets/transmission/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/transmission/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/trilium/component.test.jsx b/src/widgets/trilium/component.test.jsx
index 6d930b45d..ca7e17111 100644
--- a/src/widgets/trilium/component.test.jsx
+++ b/src/widgets/trilium/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/trilium/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/tubearchivist/component.test.jsx b/src/widgets/tubearchivist/component.test.jsx
index 5f181d233..b56b765ef 100644
--- a/src/widgets/tubearchivist/component.test.jsx
+++ b/src/widgets/tubearchivist/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/tubearchivist/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/unifi/component.test.jsx b/src/widgets/unifi/component.test.jsx
index 2947a25ef..ff2e2da00 100644
--- a/src/widgets/unifi/component.test.jsx
+++ b/src/widgets/unifi/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue, findServiceBlockByLabel } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/unifi/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/unmanic/component.test.jsx b/src/widgets/unmanic/component.test.jsx
index 94b2acea8..cdaae3970 100644
--- a/src/widgets/unmanic/component.test.jsx
+++ b/src/widgets/unmanic/component.test.jsx
@@ -4,19 +4,13 @@ import { screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/unmanic/component", () => {
const originalFetch = globalThis.fetch;
diff --git a/src/widgets/uptimekuma/component.test.jsx b/src/widgets/uptimekuma/component.test.jsx
index 7052d4a91..03065f782 100644
--- a/src/widgets/uptimekuma/component.test.jsx
+++ b/src/widgets/uptimekuma/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/uptimekuma/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/uptimerobot/component.test.jsx b/src/widgets/uptimerobot/component.test.jsx
index a5f7f3395..cce4626c0 100644
--- a/src/widgets/uptimerobot/component.test.jsx
+++ b/src/widgets/uptimerobot/component.test.jsx
@@ -4,16 +4,10 @@ import { screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/uptimerobot/component", () => {
const originalFetch = globalThis.fetch;
diff --git a/src/widgets/urbackup/component.test.jsx b/src/widgets/urbackup/component.test.jsx
index 06223dba3..125f0576e 100644
--- a/src/widgets/urbackup/component.test.jsx
+++ b/src/widgets/urbackup/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/urbackup/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/vikunja/component.test.jsx b/src/widgets/vikunja/component.test.jsx
index 494d4f49e..6968c853d 100644
--- a/src/widgets/vikunja/component.test.jsx
+++ b/src/widgets/vikunja/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/vikunja/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/wallos/component.test.jsx b/src/widgets/wallos/component.test.jsx
index f03fd03fa..7006ed3f2 100644
--- a/src/widgets/wallos/component.test.jsx
+++ b/src/widgets/wallos/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/wallos/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/watchtower/component.test.jsx b/src/widgets/watchtower/component.test.jsx
index 03cf94bdd..857c40b2b 100644
--- a/src/widgets/watchtower/component.test.jsx
+++ b/src/widgets/watchtower/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/watchtower/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/wgeasy/component.test.jsx b/src/widgets/wgeasy/component.test.jsx
index 40c1d6180..eebc83915 100644
--- a/src/widgets/wgeasy/component.test.jsx
+++ b/src/widgets/wgeasy/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/wgeasy/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/whatsupdocker/component.test.jsx b/src/widgets/whatsupdocker/component.test.jsx
index df96f8cb9..7bdcf3c45 100644
--- a/src/widgets/whatsupdocker/component.test.jsx
+++ b/src/widgets/whatsupdocker/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/whatsupdocker/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 5142ee23c..533410bdc 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -56,7 +56,6 @@ import immich from "./immich/widget";
import jackett from "./jackett/widget";
import jdownloader from "./jdownloader/widget";
import jellyfin from "./jellyfin/widget";
-import jellyseerr from "./jellyseerr/widget";
import jellystat from "./jellystat/widget";
import karakeep from "./karakeep/widget";
import kavita from "./kavita/widget";
@@ -91,7 +90,6 @@ import opendtu from "./opendtu/widget";
import openmediavault from "./openmediavault/widget";
import openwrt from "./openwrt/widget";
import opnsense from "./opnsense/widget";
-import overseerr from "./overseerr/widget";
import pangolin from "./pangolin/widget";
import paperlessngx from "./paperlessngx/widget";
import peanut from "./peanut/widget";
@@ -116,8 +114,10 @@ import romm from "./romm/widget";
import rutorrent from "./rutorrent/widget";
import sabnzbd from "./sabnzbd/widget";
import scrutiny from "./scrutiny/widget";
+import seerr from "./seerr/widget";
import slskd from "./slskd/widget";
import sonarr from "./sonarr/widget";
+import sparkyfitness from "./sparkyfitness/widget";
import speedtest from "./speedtest/widget";
import spoolman from "./spoolman/widget";
import stash from "./stash/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";
@@ -211,7 +212,7 @@ const widgets = {
jackett,
jdownloader,
jellyfin,
- jellyseerr,
+ jellyseerr: seerr,
jellystat,
kavita,
komga,
@@ -243,7 +244,7 @@ const widgets = {
ombi,
opendtu,
opnsense,
- overseerr,
+ overseerr: seerr,
openmediavault,
openwrt,
paperlessngx,
@@ -271,8 +272,10 @@ const widgets = {
rutorrent,
sabnzbd,
scrutiny,
+ seerr,
slskd,
sonarr,
+ sparkyfitness,
speedtest,
spoolman,
stash,
@@ -285,6 +288,7 @@ const widgets = {
tautulli,
technitium,
tdarr,
+ tracearr,
traefik,
transmission,
trilium,
diff --git a/src/widgets/xteve/component.test.jsx b/src/widgets/xteve/component.test.jsx
index caeb1b2e5..ee639f554 100644
--- a/src/widgets/xteve/component.test.jsx
+++ b/src/widgets/xteve/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/xteve/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/yourspotify/component.test.jsx b/src/widgets/yourspotify/component.test.jsx
index 6e60b46b7..589fcf689 100644
--- a/src/widgets/yourspotify/component.test.jsx
+++ b/src/widgets/yourspotify/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/yourspotify/component", () => {
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/widgets/zabbix/component.test.jsx b/src/widgets/zabbix/component.test.jsx
index 0795eb0bc..25047022a 100644
--- a/src/widgets/zabbix/component.test.jsx
+++ b/src/widgets/zabbix/component.test.jsx
@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
-import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component";
-function expectBlockValue(container, label, value) {
- const block = findServiceBlockByLabel(container, label);
- expect(block, `missing block for ${label}`).toBeTruthy();
- expect(block.textContent).toContain(String(value));
-}
-
describe("widgets/zabbix/component", () => {
beforeEach(() => {
vi.clearAllMocks();