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

@@ -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();
});
});