diff --git a/apps/docs/docs/widgets/downloads/index.ts b/apps/docs/docs/widgets/downloads/index.ts index 7d5b10b34..ec638a179 100644 --- a/apps/docs/docs/widgets/downloads/index.ts +++ b/apps/docs/docs/widgets/downloads/index.ts @@ -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", + }, ], }, }; diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index d7e0825fc..b6b448a2c 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -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, diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-history.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-history.ts new file mode 100644 index 000000000..2fb3c0c00 --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-history.ts @@ -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; + now?: number; +} + +const isProcessingSlot = (slot: SabnzbdHistorySlot) => slot.status !== "Completed" && slot.status !== "Failed"; + +export async function getSabnzbdHistorySlotsAsync({ + activeSlots, + historyWindowDays, + fetchPageAsync, + now = Date.now(), +}: GetSabnzbdHistorySlotsInput): Promise { + 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(); + + 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; + }); +} diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index e8f4d58a5..5a7b0ac49 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -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 { + public async getClientJobsAndStatusAsync(input: { + limit: number; + includeArchivedHistory?: boolean; + historyWindowDays?: number; + }): Promise { 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, diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts index c1bc9a249..c90cdd050 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts @@ -35,3 +35,5 @@ export const historySchema = z.object({ ), }), }); + +export type SabnzbdHistorySlot = z.infer["history"]["slots"][number]; diff --git a/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-history.spec.ts b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-history.spec.ts new file mode 100644 index 000000000..0679a533f --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-history.spec.ts @@ -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 }); + }); +}); diff --git a/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts index 9cde2689a..8ec2b2a3a 100644 --- a/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts +++ b/packages/integrations/src/download-client/sabnzbd/test/sabnzbd-integration.spec.ts @@ -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(); 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(); + }); }); diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts index 1c2b9b900..baf83b784 100644 --- a/packages/request-handler/src/downloads.ts +++ b/packages/request-handler/src/downloads.ts @@ -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, diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index ea6e2801a..e37e02173 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -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(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a3d6a30d6..64de699b7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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" }, diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index e6ac70486..4920761f2 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -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]); diff --git a/packages/widgets/src/downloads/index.spec.ts b/packages/widgets/src/downloads/index.spec.ts new file mode 100644 index 000000000..3dd8383b7 --- /dev/null +++ b/packages/widgets/src/downloads/index.spec.ts @@ -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, + ); + }); +}); diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 328cf51b4..f8e2f5463 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -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)),