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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user