feat(downloads): add option to limit amount of items (#3205)

This commit is contained in:
Meier Lukas
2025-05-24 17:49:39 +02:00
committed by GitHub
parent f7e5e823d5
commit 2dc871e531
19 changed files with 117 additions and 87 deletions

View File

@@ -19,10 +19,11 @@ const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
export const downloadsRouter = createTRPCRouter({ export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure getJobsAndStatuses: publicProcedure
.concat(createDownloadClientIntegrationMiddleware("query")) .concat(createDownloadClientIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await Promise.all( return await Promise.all(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, {}); const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
@@ -40,7 +41,8 @@ export const downloadsRouter = createTRPCRouter({
}), }),
subscribeToJobsAndStatuses: publicProcedure subscribeToJobsAndStatuses: publicProcedure
.concat(createDownloadClientIntegrationMiddleware("query")) .concat(createDownloadClientIntegrationMiddleware("query"))
.subscription(({ ctx }) => { .input(z.object({ limitPerIntegration: z.number().default(50) }))
.subscription(({ ctx, input }) => {
return observable<{ return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>; integration: Modify<Integration, { kind: IntegrationKindByCategory<"downloadClient"> }>;
data: DownloadClientJobsAndStatus; data: DownloadClientJobsAndStatus;
@@ -48,7 +50,9 @@ export const downloadsRouter = createTRPCRouter({
const unsubscribes: (() => void)[] = []; const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) { for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets; const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {}); const innerHandler = downloadClientRequestHandler.handler(integrationWithSecrets, {
limit: input.limitPerIntegration,
});
const unsubscribe = innerHandler.subscribe((data) => { const unsubscribe = innerHandler.subscribe((data) => {
emit.next({ emit.next({
integration, integration,

View File

@@ -8,7 +8,9 @@ export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCall
createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, { createRequestIntegrationJobHandler(downloadClientRequestHandler.handler, {
widgetKinds: ["downloads"], widgetKinds: ["downloads"],
getInput: { getInput: {
downloads: () => ({}), downloads: (options) => ({
limit: options.limitPerIntegration,
}),
}, },
}), }),
); );

View File

@@ -12,7 +12,7 @@ import type { DownloadClientItem } from "../../interfaces/downloads/download-cli
import type { Aria2Download, Aria2GetClient } from "./aria2-types"; import type { Aria2Download, Aria2GetClient } from "./aria2-types";
export class Aria2Integration extends DownloadClientIntegration { export class Aria2Integration extends DownloadClientIntegration {
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const client = this.getClient(); const client = this.getClient();
const keys: (keyof Aria2Download)[] = [ const keys: (keyof Aria2Download)[] = [
"bittorrent", "bittorrent",
@@ -27,12 +27,12 @@ export class Aria2Integration extends DownloadClientIntegration {
]; ];
const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([ const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([
client.tellActive(), client.tellActive(),
client.tellWaiting(0, 1000, keys), client.tellWaiting(0, input.limit, keys),
client.tellStopped(0, 1000, keys), client.tellStopped(0, input.limit, keys),
client.getGlobalStat(), client.getGlobalStat(),
]); ]);
const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads]; const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads].slice(0, input.limit);
const allPaused = downloads.every((download) => download.status === "paused"); const allPaused = downloads.every((download) => download.status === "paused");
return { return {

View File

@@ -29,9 +29,10 @@ export class DelugeIntegration extends DownloadClientIntegration {
}; };
} }
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
// Currently there is no way to limit the number of returned torrents
const { const {
stats: { download_rate, upload_rate }, stats: { download_rate, upload_rate },
torrents: rawTorrents, torrents: rawTorrents,
@@ -49,27 +50,29 @@ export class DelugeIntegration extends DownloadClientIntegration {
}, },
types: [type], types: [type],
}; };
const items = torrents.map((torrent): DownloadClientItem => { const items = torrents
const state = DelugeIntegration.getTorrentState(torrent.state); .map((torrent): DownloadClientItem => {
return { const state = DelugeIntegration.getTorrentState(torrent.state);
type, return {
id: torrent.id, type,
index: torrent.queue, id: torrent.id,
name: torrent.name, index: torrent.queue,
size: torrent.total_wanted, name: torrent.name,
sent: torrent.total_uploaded, size: torrent.total_wanted,
downSpeed: torrent.progress !== 100 ? torrent.download_payload_rate : undefined, sent: torrent.total_uploaded,
upSpeed: torrent.upload_payload_rate, downSpeed: torrent.progress !== 100 ? torrent.download_payload_rate : undefined,
time: upSpeed: torrent.upload_payload_rate,
torrent.progress === 100 time:
? Math.min((torrent.completed_time - dayjs().unix()) * 1000, -1) torrent.progress === 100
: Math.max(torrent.eta * 1000, 0), ? Math.min((torrent.completed_time - dayjs().unix()) * 1000, -1)
added: torrent.time_added * 1000, : Math.max(torrent.eta * 1000, 0),
state, added: torrent.time_added * 1000,
progress: torrent.progress / 100, state,
category: torrent.label, progress: torrent.progress / 100,
}; category: torrent.label,
}); };
})
.slice(0, input.limit);
return { status, items }; return { status, items };
} }

View File

@@ -20,7 +20,7 @@ export class NzbGetIntegration extends DownloadClientIntegration {
}; };
} }
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet"; const type = "usenet";
const queue = await this.nzbGetApiCallAsync("listgroups"); const queue = await this.nzbGetApiCallAsync("listgroups");
const history = await this.nzbGetApiCallAsync("history"); const history = await this.nzbGetApiCallAsync("history");
@@ -65,7 +65,8 @@ export class NzbGetIntegration extends DownloadClientIntegration {
category: file.Category, category: file.Category,
}; };
}), }),
); )
.slice(0, input.limit);
return { status, items }; return { status, items };
} }

View File

@@ -26,10 +26,10 @@ export class QBitTorrentIntegration extends DownloadClientIntegration {
}; };
} }
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
const torrents = await client.listTorrents(); const torrents = await client.listTorrents({ limit: input.limit });
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }),
{ down: 0, up: 0 }, { down: 0, up: 0 },

View File

@@ -22,10 +22,14 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
return { success: true }; return { success: true };
} }
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet"; const type = "usenet";
const { queue } = await queueSchema.parseAsync(await this.sabNzbApiCallAsync("queue")); const { queue } = await queueSchema.parseAsync(
const { history } = await historySchema.parseAsync(await this.sabNzbApiCallAsync("history")); await this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }),
);
const { history } = await historySchema.parseAsync(
await this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }),
);
const status: DownloadClientStatus = { const status: DownloadClientStatus = {
paused: queue.paused, paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps () rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
@@ -73,7 +77,8 @@ export class SabnzbdIntegration extends DownloadClientIntegration {
category: slot.category, category: slot.category,
}; };
}), }),
); )
.slice(0, input.limit);
return { status, items }; return { status, items };
} }

View File

@@ -23,9 +23,10 @@ export class TransmissionIntegration extends DownloadClientIntegration {
}; };
} }
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> { public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "torrent"; const type = "torrent";
const client = await this.getClientAsync(); const client = await this.getClientAsync();
// Currently there is no way to limit the number of returned torrents
const { torrents } = (await client.listTorrents()).arguments; const { torrents } = (await client.listTorrents()).arguments;
const rates = torrents.reduce( const rates = torrents.reduce(
({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }), ({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }),
@@ -34,27 +35,29 @@ export class TransmissionIntegration extends DownloadClientIntegration {
const paused = const paused =
torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined; torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined;
const status: DownloadClientStatus = { paused, rates, types: [type] }; const status: DownloadClientStatus = { paused, rates, types: [type] };
const items = torrents.map((torrent): DownloadClientItem => { const items = torrents
const state = TransmissionIntegration.getTorrentState(torrent.status); .map((torrent): DownloadClientItem => {
return { const state = TransmissionIntegration.getTorrentState(torrent.status);
type, return {
id: torrent.hashString, type,
index: torrent.queuePosition, id: torrent.hashString,
name: torrent.name, index: torrent.queuePosition,
size: torrent.totalSize, name: torrent.name,
sent: torrent.uploadedEver, size: torrent.totalSize,
downSpeed: torrent.percentDone !== 1 ? torrent.rateDownload : undefined, sent: torrent.uploadedEver,
upSpeed: torrent.rateUpload, downSpeed: torrent.percentDone !== 1 ? torrent.rateDownload : undefined,
time: upSpeed: torrent.rateUpload,
torrent.percentDone === 1 time:
? Math.min(torrent.doneDate * 1000 - dayjs().valueOf(), -1) torrent.percentDone === 1
: Math.max(torrent.eta * 1000, 0), ? Math.min(torrent.doneDate * 1000 - dayjs().valueOf(), -1)
added: torrent.addedDate * 1000, : Math.max(torrent.eta * 1000, 0),
state, added: torrent.addedDate * 1000,
progress: torrent.percentDone, state,
category: torrent.labels, progress: torrent.percentDone,
}; category: torrent.labels,
}); };
})
.slice(0, input.limit);
return { status, items }; return { status, items };
} }

View File

@@ -4,7 +4,7 @@ import type { DownloadClientItem } from "./download-client-items";
export abstract class DownloadClientIntegration extends Integration { export abstract class DownloadClientIntegration extends Integration {
/** Get download client's status and list of all of it's items */ /** Get download client's status and list of all of it's items */
public abstract getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus>; public abstract getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus>;
/** Pauses the client or all of it's items */ /** Pauses the client or all of it's items */
public abstract pauseQueueAsync(): Promise<void>; public abstract pauseQueueAsync(): Promise<void>;
/** Pause a single item using it's ID */ /** Pause a single item using it's ID */

View File

@@ -46,7 +46,7 @@ describe("Aria2 integration", () => {
// Acts // Acts
const actAsync = async () => await aria2Integration.pauseQueueAsync(); const actAsync = async () => await aria2Integration.pauseQueueAsync();
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -62,7 +62,7 @@ describe("Aria2 integration", () => {
const aria2Integration = createAria2Intergration(startedContainer, API_KEY); const aria2Integration = createAria2Intergration(startedContainer, API_KEY);
// Act // Act
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -81,7 +81,7 @@ describe("Aria2 integration", () => {
await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration); await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration);
// Act // Act
const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -104,7 +104,7 @@ describe("Aria2 integration", () => {
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
// NzbGet is slow and we wait for a second before querying the items. Test was flaky without this. // NzbGet is slow and we wait for a second before querying the items. Test was flaky without this.
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await aria2Integration.getClientJobsAndStatusAsync(); const result = await aria2Integration.getClientJobsAndStatusAsync({ limit: 99 });
expect(result.items).toHaveLength(0); expect(result.items).toHaveLength(0);
// Cleanup // Cleanup
@@ -153,7 +153,7 @@ const aria2AddItemAsync = async (container: StartedTestContainer, apiKey: string
const { const {
items: [item], items: [item],
} = await integration.getClientJobsAndStatusAsync(); } = await integration.getClientJobsAndStatusAsync({ limit: 99 });
if (!item) { if (!item) {
throw new Error("No item found"); throw new Error("No item found");

View File

@@ -69,7 +69,7 @@ describe("Nzbget integration", () => {
// Acts // Acts
const actAsync = async () => await nzbGetIntegration.pauseQueueAsync(); const actAsync = async () => await nzbGetIntegration.pauseQueueAsync();
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -87,7 +87,7 @@ describe("Nzbget integration", () => {
// Acts // Acts
const actAsync = async () => await nzbGetIntegration.resumeQueueAsync(); const actAsync = async () => await nzbGetIntegration.resumeQueueAsync();
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -105,7 +105,7 @@ describe("Nzbget integration", () => {
const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password);
// Act // Act
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -124,7 +124,7 @@ describe("Nzbget integration", () => {
await nzbGetAddItemAsync(startedContainer, username, password, nzbGetIntegration); await nzbGetAddItemAsync(startedContainer, username, password, nzbGetIntegration);
// Act // Act
const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -147,7 +147,7 @@ describe("Nzbget integration", () => {
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
// NzbGet is slow and we wait for a second before querying the items. Test was flaky without this. // NzbGet is slow and we wait for a second before querying the items. Test was flaky without this.
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await nzbGetIntegration.getClientJobsAndStatusAsync(); const result = await nzbGetIntegration.getClientJobsAndStatusAsync({ limit: 99 });
expect(result.items).toHaveLength(0); expect(result.items).toHaveLength(0);
// Cleanup // Cleanup
@@ -209,7 +209,7 @@ const nzbGetAddItemAsync = async (
const { const {
items: [item], items: [item],
} = await integration.getClientJobsAndStatusAsync(); } = await integration.getClientJobsAndStatusAsync({ limit: 99 });
if (!item) { if (!item) {
throw new Error("No item found"); throw new Error("No item found");

View File

@@ -67,7 +67,7 @@ describe("Sabnzbd integration", () => {
// Acts // Acts
const actAsync = async () => await sabnzbdIntegration.pauseQueueAsync(); const actAsync = async () => await sabnzbdIntegration.pauseQueueAsync();
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -85,7 +85,7 @@ describe("Sabnzbd integration", () => {
// Acts // Acts
const actAsync = async () => await sabnzbdIntegration.resumeQueueAsync(); const actAsync = async () => await sabnzbdIntegration.resumeQueueAsync();
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -103,7 +103,7 @@ describe("Sabnzbd integration", () => {
const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY);
// Act // Act
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -122,7 +122,7 @@ describe("Sabnzbd integration", () => {
await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration);
// Act // Act
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.not.toThrow(); await expect(getAsync()).resolves.not.toThrow();
@@ -140,7 +140,7 @@ describe("Sabnzbd integration", () => {
// Act // Act
const actAsync = async () => await sabnzbdIntegration.pauseItemAsync(item); const actAsync = async () => await sabnzbdIntegration.pauseItemAsync(item);
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] }); await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] });
@@ -160,7 +160,7 @@ describe("Sabnzbd integration", () => {
// Act // Act
const actAsync = async () => await sabnzbdIntegration.resumeItemAsync(item); const actAsync = async () => await sabnzbdIntegration.resumeItemAsync(item);
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] }); await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] });
@@ -180,7 +180,7 @@ describe("Sabnzbd integration", () => {
// Act - fromDisk already doesn't work for sabnzbd, so only test deletion itself. // Act - fromDisk already doesn't work for sabnzbd, so only test deletion itself.
const actAsync = async () => const actAsync = async () =>
await sabnzbdIntegration.deleteItemAsync({ ...item, progress: 0 } as DownloadClientItem, false); await sabnzbdIntegration.deleteItemAsync({ ...item, progress: 0 } as DownloadClientItem, false);
const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync({ limit: 99 });
// Assert // Assert
await expect(actAsync()).resolves.not.toThrow(); await expect(actAsync()).resolves.not.toThrow();
@@ -242,7 +242,7 @@ const sabNzbdAddItemAsync = async (
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const { const {
items: [item], items: [item],
} = await integration.getClientJobsAndStatusAsync(); } = await integration.getClientJobsAndStatusAsync({ limit: 99 });
if (item) return item; if (item) return item;
} }
// Throws if it can't find the item // Throws if it can't find the item

View File

@@ -77,6 +77,7 @@ const optionMapping: OptionMapping = {
descendingDefaultSort: () => false, descendingDefaultSort: () => false,
showCompletedUsenet: () => true, showCompletedUsenet: () => true,
showCompletedHttp: () => true, showCompletedHttp: () => true,
limitPerIntegration: () => undefined,
}, },
weather: { weather: {
forecastDayCount: (oldOptions) => oldOptions.forecastDays, forecastDayCount: (oldOptions) => oldOptions.forecastDays,

View File

@@ -9,11 +9,11 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const downloadClientRequestHandler = createCachedIntegrationRequestHandler< export const downloadClientRequestHandler = createCachedIntegrationRequestHandler<
DownloadClientJobsAndStatus, DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">, IntegrationKindByCategory<"downloadClient">,
Record<string, never> { limit: number }
>({ >({
async requestAsync(integration, _input) { async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync(); return await integrationInstance.getClientJobsAndStatusAsync(input);
}, },
cacheDuration: dayjs.duration(5, "seconds"), cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "downloadClientJobStatus", queryKey: "downloadClientJobStatus",

View File

@@ -1953,6 +1953,10 @@
}, },
"applyFilterToRatio": { "applyFilterToRatio": {
"label": "Use filter to calculate Ratio" "label": "Use filter to calculate Ratio"
},
"limitPerIntegration": {
"label": "Limit items per integration",
"description": "This will limit the number of items shown per integration, not globally"
} }
}, },
"errors": { "errors": {

View File

@@ -87,6 +87,7 @@ export default function DownloadClientsWidget({
const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery( const [currentItems] = clientApi.widget.downloads.getJobsAndStatuses.useSuspenseQuery(
{ {
integrationIds, integrationIds,
limitPerIntegration: options.limitPerIntegration,
}, },
{ {
refetchOnMount: false, refetchOnMount: false,
@@ -126,6 +127,7 @@ export default function DownloadClientsWidget({
clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription( clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription(
{ {
integrationIds, integrationIds,
limitPerIntegration: options.limitPerIntegration,
}, },
{ {
onData: (data) => { onData: (data) => {

View File

@@ -82,6 +82,11 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
applyFilterToRatio: factory.switch({ applyFilterToRatio: factory.switch({
defaultValue: true, defaultValue: true,
}), }),
limitPerIntegration: factory.number({
defaultValue: 50,
validate: z.number().min(1),
withDescription: true,
}),
}), }),
{ {
defaultSort: { defaultSort: {

View File

@@ -43,7 +43,7 @@ interface SelectInput<TOptions extends readonly SelectOption[]>
searchable?: boolean; searchable?: boolean;
} }
interface NumberInput extends CommonInput<number | ""> { interface NumberInput extends CommonInput<number> {
validate: z.ZodNumber; validate: z.ZodNumber;
step?: number; step?: number;
} }
@@ -87,7 +87,7 @@ const optionsFactory = {
}), }),
number: (input: NumberInput) => ({ number: (input: NumberInput) => ({
type: "number" as const, type: "number" as const,
defaultValue: input.defaultValue ?? ("" as const), defaultValue: input.defaultValue ?? 0,
step: input.step, step: input.step,
withDescription: input.withDescription ?? false, withDescription: input.withDescription ?? false,
validate: input.validate, validate: input.validate,

View File

@@ -55,7 +55,7 @@ export default function RssFeed({ options }: WidgetComponentProps<"rssFeed">) {
dir={languageDir} dir={languageDir}
c="dimmed" c="dimmed"
size="sm" size="sm"
lineClamp={options.textLinesClamp as number} lineClamp={options.textLinesClamp}
dangerouslySetInnerHTML={{ __html: feedEntry.description }} dangerouslySetInnerHTML={{ __html: feedEntry.description }}
/> />
)} )}