feat(sabnzbd): add archived history options to the Downloads widget (#6515)
This commit is contained in:
1 parent
4dbe4343e5
commit
67196975f3
13 files changed
+428
-7
No files matched your search
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+114
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
Reference in new issue
Block a user