diff --git a/docs/configs/services.md b/docs/configs/services.md
index 55d4b9217..ead69ea15 100644
--- a/docs/configs/services.md
+++ b/docs/configs/services.md
@@ -139,7 +139,7 @@ Each widget can optionally provide a list of which fields should be visible via
### Block Highlighting
-Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`).
+Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`). The custom api widget does not support highlighting.
```yaml
- Sonarr:
diff --git a/docs/widgets/services/customapi.md b/docs/widgets/services/customapi.md
index 37a48fd8c..c820a21bb 100644
--- a/docs/widgets/services/customapi.md
+++ b/docs/widgets/services/customapi.md
@@ -146,6 +146,10 @@ The widget supports different display modes that can be set using the `display`
The default display mode is `block`, which shows fields in a block format.
+!!! note
+
+ The Custom API widget does not currently support [block highlighting](../../configs/services.md#block-highlighting) in any display mode.
+
### List View
You can change the default block view to a list view by setting the `display` option to `list`.
diff --git a/docs/widgets/services/sportarr.md b/docs/widgets/services/sportarr.md
new file mode 100644
index 000000000..1faee0e83
--- /dev/null
+++ b/docs/widgets/services/sportarr.md
@@ -0,0 +1,17 @@
+---
+title: Sportarr
+description: Sportarr Widget Configuration
+---
+
+Learn more about [Sportarr](https://github.com/Sportarr/Sportarr).
+
+Find your API key under `Settings > General`.
+
+Allowed fields: `["wanted", "queued", "leagues"]`.
+
+```yaml
+widget:
+ type: sportarr
+ url: http://sportarr.host.or.ip:1867
+ key: sportarrapikeyapikeyapikeyapikeyapikey
+```
diff --git a/mkdocs.yml b/mkdocs.yml
index 5b4a53686..41543407f 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -159,6 +159,7 @@ nav:
- widgets/services/sparkyfitness.md
- widgets/services/speedtest-tracker.md
- widgets/services/spoolman.md
+ - widgets/services/sportarr.md
- widgets/services/stash.md
- widgets/services/stocks.md
- widgets/services/suwayomi.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 27d0b4c48..dfa2140e8 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -1230,5 +1230,10 @@
"synced": "Synced",
"errors": "Errors",
"storage": "Storage"
+ },
+ "sportarr": {
+ "wanted": "Wanted",
+ "queued": "Queued",
+ "leagues": "Leagues"
}
}
diff --git a/src/widgets/components.js b/src/widgets/components.js
index 657077391..2d0dd6bfc 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -133,6 +133,7 @@ const components = {
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
speedtest: dynamic(() => import("./speedtest/component")),
spoolman: dynamic(() => import("./spoolman/component")),
+ sportarr: dynamic(() => import("./sportarr/component")),
stash: dynamic(() => import("./stash/component")),
stocks: dynamic(() => import("./stocks/component")),
strelaysrv: dynamic(() => import("./strelaysrv/component")),
diff --git a/src/widgets/sportarr/component.jsx b/src/widgets/sportarr/component.jsx
new file mode 100644
index 000000000..83b906ae4
--- /dev/null
+++ b/src/widgets/sportarr/component.jsx
@@ -0,0 +1,37 @@
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import { useTranslation } from "next-i18next/pages";
+
+import useWidgetAPI from "utils/proxy/use-widget-api";
+
+export default function Component({ service }) {
+ const { t } = useTranslation();
+ const { widget } = service;
+
+ const { data: wantedData, error: wantedError } = useWidgetAPI(widget, "wanted/missing", { page: 1, pageSize: 1 });
+ const { data: queueData, error: queueError } = useWidgetAPI(widget, "queue");
+ const { data: leaguesData, error: leaguesError } = useWidgetAPI(widget, "leagues");
+
+ if (wantedError || queueError || leaguesError) {
+ const finalError = wantedError ?? queueError ?? leaguesError;
+ return ;
+ }
+
+ if (!wantedData || !queueData || !leaguesData) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/widgets/sportarr/component.test.jsx b/src/widgets/sportarr/component.test.jsx
new file mode 100644
index 000000000..b88b46bdd
--- /dev/null
+++ b/src/widgets/sportarr/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/sportarr/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("sportarr.wanted")).toBeInTheDocument();
+ expect(screen.getByText("sportarr.queued")).toBeInTheDocument();
+ expect(screen.getByText("sportarr.leagues")).toBeInTheDocument();
+ });
+
+ it("renders counts when all endpoints resolve", () => {
+ useWidgetAPI.mockImplementation((_widget, endpoint) => {
+ if (endpoint === "wanted/missing") return { data: { totalRecords: 4 }, error: undefined };
+ if (endpoint === "queue") return { data: { total: 2 }, error: undefined };
+ if (endpoint === "leagues") return { data: { total: 7 }, error: undefined };
+ return { data: undefined, error: undefined };
+ });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expectBlockValue(container, "sportarr.wanted", 4);
+ expectBlockValue(container, "sportarr.queued", 2);
+ expectBlockValue(container, "sportarr.leagues", 7);
+ });
+
+ it("renders the error state when an endpoint fails", () => {
+ useWidgetAPI.mockImplementation((_widget, endpoint) => {
+ if (endpoint === "queue") return { data: undefined, error: { message: "unreachable" } };
+ return { data: undefined, error: undefined };
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/widgets/sportarr/widget.js b/src/widgets/sportarr/widget.js
new file mode 100644
index 000000000..a31e252a3
--- /dev/null
+++ b/src/widgets/sportarr/widget.js
@@ -0,0 +1,25 @@
+import { asJson } from "utils/proxy/api-helpers";
+import genericProxyHandler from "utils/proxy/handlers/generic";
+
+const widget = {
+ api: "{url}/api/{endpoint}?apikey={key}",
+ proxyHandler: genericProxyHandler,
+
+ mappings: {
+ "wanted/missing": {
+ endpoint: "wanted/missing",
+ params: ["page", "pageSize"],
+ validate: ["totalRecords"],
+ },
+ queue: {
+ endpoint: "queue",
+ map: (data) => ({ total: asJson(data).length }),
+ },
+ leagues: {
+ endpoint: "leagues",
+ map: (data) => ({ total: asJson(data).length }),
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/sportarr/widget.test.js b/src/widgets/sportarr/widget.test.js
new file mode 100644
index 000000000..922ddffb5
--- /dev/null
+++ b/src/widgets/sportarr/widget.test.js
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("sportarr widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ });
+
+ it("maps the queue response to a total count", () => {
+ const result = widget.mappings.queue.map(Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }, { id: 3 }])));
+
+ expect(result).toEqual({ total: 3 });
+ });
+
+ it("maps the leagues response to a total count", () => {
+ const result = widget.mappings.leagues.map(Buffer.from(JSON.stringify([{ id: 1, name: "Formula 1" }])));
+
+ expect(result).toEqual({ total: 1 });
+ });
+
+ it("validates totalRecords on the wanted endpoint", () => {
+ expect(widget.mappings["wanted/missing"].validate).toEqual(["totalRecords"]);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 3ccaddd25..17be5aedc 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -123,6 +123,7 @@ import sonarr from "./sonarr/widget";
import sparkyfitness from "./sparkyfitness/widget";
import speedtest from "./speedtest/widget";
import spoolman from "./spoolman/widget";
+import sportarr from "./sportarr/widget";
import stash from "./stash/widget";
import stocks from "./stocks/widget";
import strelaysrv from "./strelaysrv/widget";
@@ -286,6 +287,7 @@ const widgets = {
sparkyfitness,
speedtest,
spoolman,
+ sportarr,
stash,
stocks,
strelaysrv,