diff --git a/docs/widgets/services/index.md b/docs/widgets/services/index.md
index d99752e0f..270a7492e 100644
--- a/docs/widgets/services/index.md
+++ b/docs/widgets/services/index.md
@@ -120,6 +120,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [PyLoad](pyload.md)
- [qBittorrent](qbittorrent.md)
- [QNAP](qnap.md)
+- [qui](qui.md)
- [Radarr](radarr.md)
- [Readarr](readarr.md)
- [ROMM](romm.md)
diff --git a/docs/widgets/services/qui.md b/docs/widgets/services/qui.md
new file mode 100644
index 000000000..9d5de1100
--- /dev/null
+++ b/docs/widgets/services/qui.md
@@ -0,0 +1,30 @@
+---
+title: qui
+description: qui Widget Configuration
+---
+
+Learn more about [qui](https://github.com/autobrr/qui).
+
+Generate an API key in qui under **Settings → API Keys**.
+
+The widget has two modes:
+
+- **Aggregated (default):** omit `instance` to show combined stats across all qBittorrent instances monitored by qui.
+- **Per-instance:** set `instance` to the qui ID (visible in the qui UI) to show just that one.
+
+```yaml
+widget:
+ type: qui
+ url: http://qui.host.or.ip:7476
+ key: quiapikeyquiapikeyquiapikey
+ instance: 1 # optional; omit for aggregated stats across all instances
+ fields: ["leech", "download", "seed", "upload"] # optional
+```
+
+## Fields
+
+Allowed fields: `["leech", "download", "seed", "upload", "total", "errored", "ratio", "freeSpace"]` (maximum of 4).
+
+Default fields: `["leech", "download", "seed", "upload"]`.
+
+Note: `ratio` and `freeSpace` are only available with the 'single instance' version of the widget.
diff --git a/mkdocs.yml b/mkdocs.yml
index 6c38350a2..6edfcebc4 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -149,6 +149,7 @@ nav:
- widgets/services/pyload.md
- widgets/services/qbittorrent.md
- widgets/services/qnap.md
+ - widgets/services/qui.md
- widgets/services/radarr.md
- widgets/services/readarr.md
- widgets/services/romm.md
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index b29a4452a..4363427d7 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -256,6 +256,16 @@
"leech": "Leech",
"seed": "Seed"
},
+ "qui": {
+ "leech": "Leech",
+ "download": "Download",
+ "seed": "Seed",
+ "upload": "Upload",
+ "total": "Total",
+ "errored": "Errored",
+ "ratio": "Ratio",
+ "freeSpace": "Free"
+ },
"qnap": {
"cpuUsage": "CPU Usage",
"memUsage": "MEM Usage",
diff --git a/src/utils/config/service-helpers.js b/src/utils/config/service-helpers.js
index 20303af7d..98375b765 100644
--- a/src/utils/config/service-helpers.js
+++ b/src/utils/config/service-helpers.js
@@ -433,6 +433,9 @@ export function cleanServiceGroups(groups) {
// proxmoxbackupserver
datastore,
+ // qui
+ instance,
+
// speedtest
bitratePrecision,
@@ -543,6 +546,9 @@ export function cleanServiceGroups(groups) {
if (type === "portainer") {
if (kubernetes) widget.kubernetes = !!JSON.parse(kubernetes);
}
+ if (type === "qui") {
+ if (instance !== undefined) widget.instance = instance;
+ }
if (type === "proxmox") {
if (node) widget.node = node;
}
diff --git a/src/utils/config/service-helpers.test.js b/src/utils/config/service-helpers.test.js
index 6d6174526..07be96d28 100644
--- a/src/utils/config/service-helpers.test.js
+++ b/src/utils/config/service-helpers.test.js
@@ -403,6 +403,7 @@ describe("utils/config/service-helpers", () => {
{ type: "radarr", enableQueue: "true" },
{ type: "truenas", enablePools: "true", nasType: "scale" },
{ type: "qnap", volume: "vol1" },
+ { type: "qui", instance: 2 },
{ type: "dispatcharr", enableActiveStreams: "true" },
{ type: "gamedig", gameToken: "t" },
{ type: "kopia", snapshotHost: "h", snapshotPath: "/p" },
@@ -468,6 +469,7 @@ describe("utils/config/service-helpers", () => {
expect.objectContaining({ namespace: "default", app: "app", podSelector: "app=test" }),
);
expect(widgets.find((w) => w.type === "qnap")).toEqual(expect.objectContaining({ volume: "vol1" }));
+ expect(widgets.find((w) => w.type === "qui")).toEqual(expect.objectContaining({ instance: 2 }));
expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }),
);
diff --git a/src/widgets/components.js b/src/widgets/components.js
index ae8327578..7ae5ff9de 100644
--- a/src/widgets/components.js
+++ b/src/widgets/components.js
@@ -123,6 +123,7 @@ const components = {
pyload: dynamic(() => import("./pyload/component")),
qbittorrent: dynamic(() => import("./qbittorrent/component")),
qnap: dynamic(() => import("./qnap/component")),
+ qui: dynamic(() => import("./qui/component")),
radarr: dynamic(() => import("./radarr/component")),
readarr: dynamic(() => import("./readarr/component")),
romm: dynamic(() => import("./romm/component")),
diff --git a/src/widgets/qui/component.jsx b/src/widgets/qui/component.jsx
new file mode 100644
index 000000000..4cb71bc02
--- /dev/null
+++ b/src/widgets/qui/component.jsx
@@ -0,0 +1,77 @@
+import { useTranslation } from "next-i18next/pages";
+
+import Block from "components/services/widget/block";
+import Container from "components/services/widget/container";
+import useWidgetAPI from "utils/proxy/use-widget-api";
+import withWidgetFields from "utils/widget-fields";
+
+const DEFAULT_FIELDS = ["leech", "download", "seed", "upload"];
+
+export default function Component({ service: configuredService }) {
+ const { t } = useTranslation();
+ const service = withWidgetFields(configuredService, DEFAULT_FIELDS);
+ const { widget } = service;
+
+ // A specific instance id selects per-instance stats; otherwise aggregate all instances.
+ const perInstance = widget.instance != null && widget.instance !== "";
+ const mapping = perInstance ? "torrents" : "torrentsAll";
+ const { data: torrentData, error: torrentError } = useWidgetAPI(widget, mapping);
+
+ if (torrentError) {
+ return ;
+ }
+
+ if (!torrentData || !torrentData.stats) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const { stats } = torrentData;
+ const status = torrentData.counts?.status;
+ const serverState = torrentData.serverState;
+
+ // counts.status uses qBittorrent's sidebar semantics (complete vs incomplete). Seed/Leech show
+ // "active / total" — actively transferring over complete/incomplete. Fall back to stats-only.
+ const completed = status ? status.completed : stats.seeding;
+ const incomplete = status ? status.all - status.completed : stats.downloading;
+ const seedValue = `${t("common.number", { value: stats.seeding })} / ${t("common.number", { value: completed })}`;
+ const leechValue = `${t("common.number", { value: stats.downloading })} / ${t("common.number", { value: incomplete })}`;
+
+ return (
+
+
+
+
+
+
+
+ {serverState && (
+
+ )}
+ {serverState && (
+
+ )}
+
+ );
+}
diff --git a/src/widgets/qui/component.test.jsx b/src/widgets/qui/component.test.jsx
new file mode 100644
index 000000000..f54b4a86f
--- /dev/null
+++ b/src/widgets/qui/component.test.jsx
@@ -0,0 +1,149 @@
+// @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";
+
+// Aggregated response (cross-instance) — no serverState, as qui can't merge it.
+const aggregatedData = {
+ stats: { downloading: 1, seeding: 2, totalDownloadSpeed: 100, totalUploadSpeed: 200, total: 10, error: 0 },
+ counts: { status: { all: 10, completed: 8, errored: 3 } },
+};
+
+// Per-instance response — includes serverState (ratio / free space).
+const perInstanceData = {
+ stats: { downloading: 0, seeding: 3, totalDownloadSpeed: 0, totalUploadSpeed: 141939, total: 6477, error: 0 },
+ counts: { status: { all: 6477, completed: 6477, errored: 0 } },
+ serverState: { global_ratio: "8.58", free_space_on_disk: 49820224065536 },
+};
+
+describe("widgets/qui/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders four placeholder blocks while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("qui.leech")).toBeInTheDocument();
+ expect(screen.getByText("qui.download")).toBeInTheDocument();
+ expect(screen.getByText("qui.seed")).toBeInTheDocument();
+ expect(screen.getByText("qui.upload")).toBeInTheDocument();
+ });
+
+ it("shows placeholders for selected optional fields while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "qui", instance: 1, fields: ["seed", "ratio"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ // Container filters placeholders by fields, so an optional placeholder must exist to be shown.
+ expect(container.querySelectorAll(".service-block")).toHaveLength(2);
+ expect(screen.getByText("qui.seed")).toBeInTheDocument();
+ expect(screen.getByText("qui.ratio")).toBeInTheDocument();
+ });
+
+ it("renders error UI when the endpoint 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("uses the aggregated endpoint and shows active/total for seed and leech", () => {
+ useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ // No instance id -> aggregated (cross-instance) mapping.
+ expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "qui.leech", "1 / 2"); // active downloading / incomplete (all - completed)
+ expectBlockValue(container, "qui.download", "100");
+ expectBlockValue(container, "qui.seed", "2 / 8"); // active seeding / completed
+ expectBlockValue(container, "qui.upload", "200");
+ });
+
+ it("uses the per-instance endpoint and renders serverState-backed ratio and freeSpace", () => {
+ useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
+
+ const service = { widget: { type: "qui", instance: 1, fields: ["seed", "upload", "ratio", "freeSpace"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ // Instance id set -> per-instance mapping.
+ expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrents");
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "qui.seed", "3 / 6477");
+ expectBlockValue(container, "qui.upload", "141939");
+ expectBlockValue(container, "qui.ratio", "8.58");
+ expectBlockValue(container, "qui.freeSpace", "49820224065536");
+ });
+
+ it("renders the optional total and errored fields when selected", () => {
+ useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
+
+ const service = { widget: { type: "qui", fields: ["total", "errored"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(2);
+ expectBlockValue(container, "qui.total", "10");
+ expectBlockValue(container, "qui.errored", "3");
+ });
+
+ it("falls back to stats when counts are missing", () => {
+ const { counts, ...statsOnly } = aggregatedData;
+ useWidgetAPI.mockReturnValue({ data: statsOnly, error: undefined });
+
+ const service = { widget: { type: "qui", instance: "", fields: ["leech", "seed", "total", "errored"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(useWidgetAPI).toHaveBeenCalledWith(expect.anything(), "torrentsAll");
+ expectBlockValue(container, "qui.leech", "1 / 1");
+ expectBlockValue(container, "qui.seed", "2 / 2");
+ expectBlockValue(container, "qui.total", "10");
+ expectBlockValue(container, "qui.errored", "0");
+ });
+
+ it("caps configured fields at four blocks", () => {
+ useWidgetAPI.mockReturnValue({ data: perInstanceData, error: undefined });
+
+ const service = {
+ widget: { type: "qui", instance: 1, fields: ["total", "errored", "ratio", "freeSpace", "leech", "seed"] },
+ };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.queryByText("qui.leech")).toBeNull();
+ expect(screen.queryByText("qui.seed")).toBeNull();
+ });
+
+ it("omits ratio/freeSpace in aggregated mode where serverState is absent", () => {
+ useWidgetAPI.mockReturnValue({ data: aggregatedData, error: undefined });
+
+ const service = { widget: { type: "qui", fields: ["seed", "ratio", "freeSpace"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(screen.getByText("qui.seed")).toBeInTheDocument();
+ expect(screen.queryByText("qui.ratio")).toBeNull();
+ expect(screen.queryByText("qui.freeSpace")).toBeNull();
+ expect(container.querySelectorAll(".service-block")).toHaveLength(1);
+ });
+});
diff --git a/src/widgets/qui/widget.js b/src/widgets/qui/widget.js
new file mode 100644
index 000000000..31b9f7f6f
--- /dev/null
+++ b/src/widgets/qui/widget.js
@@ -0,0 +1,19 @@
+import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
+
+const widget = {
+ api: "{url}/api/{endpoint}",
+ proxyHandler: credentialedProxyHandler,
+
+ mappings: {
+ torrents: {
+ endpoint: "instances/{instance}/torrents?limit=1",
+ validate: ["stats"],
+ },
+ torrentsAll: {
+ endpoint: "torrents/cross-instance?limit=1",
+ validate: ["stats"],
+ },
+ },
+};
+
+export default widget;
diff --git a/src/widgets/qui/widget.test.js b/src/widgets/qui/widget.test.js
new file mode 100644
index 000000000..2a947778e
--- /dev/null
+++ b/src/widgets/qui/widget.test.js
@@ -0,0 +1,11 @@
+import { describe, it } from "vitest";
+
+import { expectWidgetConfigShape } from "test-utils/widget-config";
+
+import widget from "./widget";
+
+describe("qui widget config", () => {
+ it("exports a valid widget config", () => {
+ expectWidgetConfigShape(widget);
+ });
+});
diff --git a/src/widgets/widgets.js b/src/widgets/widgets.js
index 5716675d0..3691e648f 100644
--- a/src/widgets/widgets.js
+++ b/src/widgets/widgets.js
@@ -113,6 +113,7 @@ import pulse from "./pulse/widget";
import pyload from "./pyload/widget";
import qbittorrent from "./qbittorrent/widget";
import qnap from "./qnap/widget";
+import qui from "./qui/widget";
import radarr from "./radarr/widget";
import readarr from "./readarr/widget";
import romm from "./romm/widget";
@@ -279,6 +280,7 @@ const widgets = {
pyload,
qbittorrent,
qnap,
+ qui,
radarr,
readarr,
romm,