feat(sabnzbd): add archived history options to the Downloads widget (#6515)

This commit is contained in:
Sobe Silvertree authored and GitHub committed 2026-10-05 14:51:03 +02:00
1 parent 4dbe4343e5
commit 67196975f3
13 files changed
+428 -7

No files matched your search

+14
View File
@@ -98,6 +98,20 @@ export const downloadsWidget: WidgetDefinition = {
values: "Any whole number from 1 upward",
defaultValue: "50",
},
{
name: "Include archived SABnzbd history",
description:
"When enabled, the selected day window applies to finished normal and archived SABnzbd history. Queue and processing jobs remain visible.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Archived history window (days)",
description:
"At most 10,000 archived records are scanned. The item limit still controls how many rows are shown.",
values: "Any positive whole number of days",
defaultValue: "7",
},
],
},
};
+13 -3
View File
@@ -52,16 +52,26 @@ export const downloadsRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50). For SABnzbd, includeArchivedHistory includes archived jobs when true and defaults to false; historyWindowDays is a positive whole number of days and defaults to 7. Archived history is scanned in pages of 100, up to 100 pages.",
},
})
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.input(
z.object({
limitPerIntegration: z.number().default(50),
includeArchivedHistory: z.boolean().default(false),
historyWindowDays: z.number().int().min(1).default(7),
}),
)
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const innerHandler = downloadClientRequestHandler.handler(integration, {
limit: input.limitPerIntegration,
includeArchivedHistory: input.includeArchivedHistory,
historyWindowDays: input.historyWindowDays,
});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
@@ -0,0 +1,49 @@
import type { SabnzbdHistorySlot } from "./sabnzbd-schema";
const SECONDS_PER_DAY = 24 * 60 * 60;
const ARCHIVED_HISTORY_PAGE_SIZE = 100;
const MAX_ARCHIVED_HISTORY_PAGES = 100;
interface GetSabnzbdHistorySlotsInput {
activeSlots: SabnzbdHistorySlot[];
historyWindowDays: number;
fetchPageAsync: (input: { start: number; limit: number }) => Promise<SabnzbdHistorySlot[]>;
now?: number;
}
const isProcessingSlot = (slot: SabnzbdHistorySlot) => slot.status !== "Completed" && slot.status !== "Failed";
export async function getSabnzbdHistorySlotsAsync({
activeSlots,
historyWindowDays,
fetchPageAsync,
now = Date.now(),
}: GetSabnzbdHistorySlotsInput): Promise<SabnzbdHistorySlot[]> {
const cutoff = Math.floor(now / 1000) - historyWindowDays * SECONDS_PER_DAY;
const archivedSlots: SabnzbdHistorySlot[] = [];
for (let page = 0; page < MAX_ARCHIVED_HISTORY_PAGES; page += 1) {
const slots = await fetchPageAsync({
start: page * ARCHIVED_HISTORY_PAGE_SIZE,
limit: ARCHIVED_HISTORY_PAGE_SIZE,
});
archivedSlots.push(...slots);
if (slots.length < ARCHIVED_HISTORY_PAGE_SIZE || slots.every((slot) => slot.completed < cutoff)) {
break;
}
}
const seenIds = new Set<string>();
return [
...activeSlots.filter(isProcessingSlot),
...[...activeSlots, ...archivedSlots]
.filter((slot) => !isProcessingSlot(slot) && slot.completed >= cutoff)
.toSorted((first, second) => second.completed - first.completed),
].filter((slot) => {
if (seenIds.has(slot.nzo_id)) return false;
seenIds.add(slot.nzo_id);
return true;
});
}
@@ -12,6 +12,7 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status";
import { getSabnzbdHistorySlotsAsync } from "./sabnzbd-history";
import { historySchema, queueSchema } from "./sabnzbd-schema";
dayjs.extend(duration);
@@ -23,7 +24,11 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
return { success: true };
}
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
public async getClientJobsAndStatusAsync(input: {
limit: number;
includeArchivedHistory?: boolean;
historyWindowDays?: number;
}): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const [queueResult, historyResult] = await Promise.all([
this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) =>
@@ -35,6 +40,24 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
]);
const { queue } = queueResult;
const { history } = historyResult;
let historySlots = history.slots;
if (input.includeArchivedHistory) {
historySlots = await getSabnzbdHistorySlotsAsync({
activeSlots: history.slots,
historyWindowDays: input.historyWindowDays ?? 7,
fetchPageAsync: async ({ start, limit }) => {
const { history: archivedHistory } = await historySchema.parseAsync(
await this.sabNzbApiCallAsync("history", {
archive: "1",
start: start.toString(),
limit: limit.toString(),
}),
);
return archivedHistory.slots;
},
});
}
const status: DownloadClientStatus = {
paused: queue.paused,
// SABnzbd's legacy kbpersec field is kibibytes per second; normalize it to bytes per second.
@@ -68,7 +91,7 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
};
})
.concat(
history.slots.map((slot, index): DownloadClientItem => {
historySlots.map((slot, index): DownloadClientItem => {
const state = SabnzbdIntegration.getUsenetHistoryState(slot.status);
return {
type,
@@ -35,3 +35,5 @@ export const historySchema = z.object({
),
}),
});
export type SabnzbdHistorySlot = z.infer<typeof historySchema>["history"]["slots"][number];
@@ -0,0 +1,115 @@
import { describe, expect, test, vi } from "vitest";
import type { SabnzbdHistorySlot } from "../sabnzbd-schema";
import { getSabnzbdHistorySlotsAsync } from "../sabnzbd-history";
const NOW = Date.UTC(2026, 7, 10);
function createSlot(id: string, daysAgo: number): SabnzbdHistorySlot {
return {
category: "test",
download_time: 60,
status: "Completed",
completed: Math.floor(NOW / 1000) - daysAgo * 24 * 60 * 60,
nzo_id: id,
postproc_time: 30,
name: id,
bytes: 1000,
};
}
describe("getSabnzbdHistorySlotsAsync", () => {
test("merges active and archived history, filters by window, sorts newest first, and deduplicates", async () => {
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [createSlot("active", 1), createSlot("duplicate", 3)],
historyWindowDays: 10,
now: NOW,
fetchPageAsync: vi
.fn()
.mockResolvedValue([createSlot("archived", 2), createSlot("duplicate", 3), createSlot("expired", 11)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["active", "archived", "duplicate"]);
});
test.each([1, 2, 7, 14, 28])("includes the exact %i-day cutoff", async (historyWindowDays) => {
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays,
now: NOW,
fetchPageAsync: vi
.fn()
.mockResolvedValue([createSlot("inside", historyWindowDays), createSlot("outside", historyWindowDays + 1)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["inside"]);
});
test("requests another page when the archive page is full", async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => createSlot(`page-1-${index}`, 1));
const fetchPageAsync = vi
.fn()
.mockResolvedValueOnce(firstPage)
.mockResolvedValueOnce([createSlot("page-2", 2)]);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenNthCalledWith(1, { start: 0, limit: 100 });
expect(fetchPageAsync).toHaveBeenNthCalledWith(2, { start: 100, limit: 100 });
expect(fetchPageAsync).toHaveBeenCalledTimes(2);
});
test("stops after a full page that is entirely outside the selected window", async () => {
const expiredPage = Array.from({ length: 100 }, (_, index) => createSlot(`expired-${index}`, 11));
const fetchPageAsync = vi.fn().mockResolvedValue(expiredPage);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenCalledTimes(1);
});
test("stops after a partial archive page", async () => {
const fetchPageAsync = vi.fn().mockResolvedValue([createSlot("archived", 1)]);
await getSabnzbdHistorySlotsAsync({
activeSlots: [],
historyWindowDays: 10,
now: NOW,
fetchPageAsync,
});
expect(fetchPageAsync).toHaveBeenCalledTimes(1);
});
test("keeps processing jobs ahead of finished history even without a completion date", async () => {
const processingSlot = { ...createSlot("processing", 0), status: "Extracting", completed: 0 };
const result = await getSabnzbdHistorySlotsAsync({
activeSlots: [processingSlot, createSlot("expired", 8)],
historyWindowDays: 7,
now: NOW,
fetchPageAsync: vi.fn().mockResolvedValue([createSlot("archived", 1)]),
});
expect(result.map((slot) => slot.nzo_id)).toEqual(["processing", "archived"]);
});
test("bounds requests when the server keeps returning a full recent page", async () => {
const recentPage = Array.from({ length: 100 }, (_, index) => createSlot(`recent-${index}`, 1));
const fetchPageAsync = vi.fn().mockResolvedValue(recentPage);
await getSabnzbdHistorySlotsAsync({ activeSlots: [], historyWindowDays: 7, now: NOW, fetchPageAsync });
expect(fetchPageAsync).toHaveBeenCalledTimes(100);
expect(fetchPageAsync).toHaveBeenLastCalledWith({ start: 9900, limit: 100 });
});
});
@@ -1,7 +1,7 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
@@ -12,6 +12,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import type { IntegrationSecret } from "../../../base/types";
import { SabnzbdIntegration } from "../sabnzbd-integration";
import type { SabnzbdHistorySlot } from "../sabnzbd-schema";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
@@ -19,6 +20,17 @@ vi.mock("@homarr/core/infrastructure/http", () => ({
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }];
const NOW = Date.UTC(2026, 9, 4);
const createHistorySlot = (id: string, daysAgo: number): SabnzbdHistorySlot => ({
category: "test",
download_time: 60,
status: "Completed",
completed: Math.floor(NOW / 1000) - daysAgo * 24 * 60 * 60,
nzo_id: id,
postproc_time: 30,
name: id,
bytes: 1000,
});
const createIntegration = () =>
new SabnzbdIntegration({
@@ -34,6 +46,10 @@ describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
mockFetch.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
test("starts queue and history requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedModes: string[] = [];
@@ -68,4 +84,101 @@ describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
items: [],
});
});
test.each([undefined, false])(
"keeps normal history when archive is disabled (%s)",
async (includeArchivedHistory) => {
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
expect(params.has("archive")).toBe(false);
const result =
params.get("mode") === "queue"
? { queue: { paused: false, kbpersec: "0", slots: [] } }
: { history: { slots: [createHistorySlot("old-normal", 30)] } };
return new Response(JSON.stringify(result));
});
const result = await createIntegration().getClientJobsAndStatusAsync({
limit: 10,
includeArchivedHistory,
historyWindowDays: 1,
});
expect(result.items.map((item) => item.id)).toEqual(["old-normal"]);
expect(mockFetch).toHaveBeenCalledTimes(2);
},
);
test("merges archived history without displacing active jobs and applies the row limit", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
let result;
if (params.get("mode") === "queue") {
result = {
queue: {
paused: false,
kbpersec: "1",
slots: [
{
status: "Downloading",
index: 0,
mb: "1",
filename: "queue",
cat: "test",
timeleft: "0:00:10",
percentage: "50",
nzo_id: "queue",
},
],
},
};
} else if (params.get("archive") === "1") {
expect(params.get("start")).toBe("0");
expect(params.get("limit")).toBe("100");
result = { history: { slots: [createHistorySlot("archived", 3), createHistorySlot("duplicate", 2)] } };
} else {
result = {
history: {
slots: [
{ ...createHistorySlot("processing", 0), status: "Extracting", completed: 0 },
createHistorySlot("normal", 1),
createHistorySlot("duplicate", 2),
createHistorySlot("expired", 8),
],
},
};
}
return new Response(JSON.stringify(result));
});
const integration = createIntegration();
const input = { limit: 10, includeArchivedHistory: true, historyWindowDays: 7 };
const result = await integration.getClientJobsAndStatusAsync(input);
expect(result.items.map((item) => item.id)).toEqual(["queue", "processing", "normal", "duplicate", "archived"]);
expect(result.status.rates.down).toBe(1024);
const limited = await integration.getClientJobsAndStatusAsync({ ...input, limit: 3 });
expect(limited.items.map((item) => item.id)).toEqual(["queue", "processing", "normal"]);
});
test("rejects malformed archive timestamps through the existing history schema", async () => {
mockFetch.mockImplementation(async (url) => {
const params = new URL(String(url)).searchParams;
const result =
params.get("mode") === "queue"
? { queue: { paused: false, kbpersec: "0", slots: [] } }
: {
history: {
slots: params.has("archive") ? [{ ...createHistorySlot("invalid", 0), completed: "bad" }] : [],
},
};
return new Response(JSON.stringify(result));
});
await expect(
createIntegration().getClientJobsAndStatusAsync({ limit: 10, includeArchivedHistory: true }),
).rejects.toThrow();
});
});
+5 -1
View File
@@ -7,7 +7,11 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number }
{
limit: number;
includeArchivedHistory: boolean;
historyWindowDays: number;
}
>({
cacheNamespace: "downloads:jobs-and-status",
cacheTtlMs: 10 * 1000,
@@ -322,6 +322,30 @@ describe("createRequestHandler", () => {
});
describe("createIntegrationRequestHandler", () => {
it("keeps SABnzbd history windows and archive modes in separate cache entries", async () => {
let calls = 0;
const handler = createIntegrationRequestHandler<
number,
"sabNzbd",
{ limit: number; includeArchivedHistory: boolean; historyWindowDays: number }
>({ requestAsync: async () => ++calls });
const integration = { ...createIntegration("test-secret"), kind: "sabNzbd" as const };
const input = { limit: 50, includeArchivedHistory: true, historyWindowDays: 7 };
await expect(handler.handler(integration, input).getDataAsync()).resolves.toMatchObject({ data: 1 });
await expect(handler.handler(integration, input).getDataAsync()).resolves.toMatchObject({ data: 1 });
await expect(
handler.handler(integration, { ...input, historyWindowDays: 14 }).getDataAsync(),
).resolves.toMatchObject({ data: 2 });
await expect(
handler.handler(integration, { ...input, includeArchivedHistory: false }).getDataAsync(),
).resolves.toMatchObject({ data: 3 });
await expect(
handler.handler(integration, { ...input, historyWindowDays: 14 }).getDataAsync(),
).resolves.toMatchObject({ data: 2 });
expect(calls).toBe(3);
});
it("does not deduplicate requests across rotated integration secrets", async () => {
let calls = 0;
const request = deferred<string>();
+8
View File
@@ -4137,6 +4137,14 @@
"showCompletedHttp": {
"label": "Show Miscellaneous entries marked as completed"
},
"includeArchivedHistory": {
"label": "Include archived SABnzbd history",
"description": "Show jobs moved to Archive."
},
"historyWindowDays": {
"label": "Archived history window (days)",
"description": "Limit finished SABnzbd history to this many days. Increase the item limit to show more results."
},
"activeTorrentThreshold": {
"label": "Hide completed torrent under this threshold"
},
@@ -319,6 +319,8 @@ export default function DownloadClientsWidget({
const downloadsQuery = clientApi.widget.downloads.getJobsAndStatuses.useQuery({
integrationIds,
limitPerIntegration: options.limitPerIntegration,
includeArchivedHistory: options.includeArchivedHistory,
historyWindowDays: options.historyWindowDays,
});
const currentItems = getUsableWidgetQueryData(downloadsQuery);
const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]);
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { definition } from ".";
describe("Downloads widget history options", () => {
test.each([1, 7, 14, 28])("accepts an arbitrary %i-day window", (days) => {
const options = definition.createOptions();
expect(options.historyWindowDays.validate.safeParse(days).success).toBe(true);
});
test.each([0, -1, 1.5, "7"])("rejects an invalid day window %s", (days) => {
const options = definition.createOptions();
expect(options.historyWindowDays.validate.safeParse(days).success).toBe(false);
});
test("only matches results for its own archive settings", () => {
const input = {
integrationIds: ["sabnzbd-1"],
limitPerIntegration: 50,
includeArchivedHistory: true,
historyWindowDays: 7,
};
const scope = {
itemId: "widget-1",
boardId: "board-1",
integrationIds: input.integrationIds,
options: input,
runtimeQueries: [],
};
const query = { path: ["widget", "downloads", "getJobsAndStatuses"], input };
expect(definition.queryMatcher(query, scope)).toBe(true);
expect(definition.queryMatcher({ ...query, input: { ...input, historyWindowDays: 14 } }, scope)).toBe(false);
expect(definition.queryMatcher({ ...query, input: { ...input, includeArchivedHistory: false } }, scope)).toBe(
false,
);
});
});
+19
View File
@@ -30,6 +30,8 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
widgetQueryInputMatches(input, {
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
includeArchivedHistory: scope.options.includeArchivedHistory,
historyWindowDays: scope.options.historyWindowDays,
}),
refetchInterval: 10,
createOptions() {
@@ -62,6 +64,16 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
showCompletedHttp: factory.switch({
defaultValue: true,
}),
includeArchivedHistory: factory.switch({
defaultValue: false,
withDescription: true,
}),
historyWindowDays: factory.number({
defaultValue: 7,
validate: z.number().int().min(1),
step: 1,
withDescription: true,
}),
activeTorrentThreshold: factory.number({
validate: z.number().min(0),
defaultValue: 0,
@@ -89,6 +101,13 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
includeArchivedHistory: {
shouldHide: (_, integrationKinds) => !integrationKinds.includes("sabNzbd"),
},
historyWindowDays: {
shouldHide: ({ includeArchivedHistory }, integrationKinds) =>
!integrationKinds.includes("sabNzbd") || !includeArchivedHistory,
},
showCompletedUsenet: {
shouldHide: (_, integrationKinds) =>
!getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)),