From 94263c445b8a0d0cdbea70ebe45ef932b426204d Mon Sep 17 00:00:00 2001 From: Kudou Sterain <40029631+hotrungnhan@users.noreply.github.com> Date: Fri, 11 Apr 2025 02:40:40 +0700 Subject: [PATCH] feat: support aria2 integration (#2226) --- packages/definitions/src/integration.ts | 7 + packages/integrations/package.json | 1 + packages/integrations/src/base/creator.ts | 2 + .../aria2/aria2-integration.ts | 180 ++++++++++++++++++ .../src/download-client/aria2/aria2-types.ts | 80 ++++++++ .../deluge/deluge-integration.ts | 2 +- .../nzbget/nzbget-integration.ts | 2 +- .../qbittorrent/qbittorrent-integration.ts | 2 +- .../sabnzbd/sabnzbd-integration.ts | 2 +- .../transmission/transmission-integration.ts | 2 +- packages/integrations/src/index.ts | 1 + .../downloads/download-client-items.ts | 4 +- .../downloads/download-client-status.ts | 2 +- packages/integrations/test/aria2.spec.ts | 153 +++++++++++++++ packages/old-import/src/widgets/options.ts | 1 + packages/translation/src/lang/en.json | 3 + packages/widgets/src/downloads/component.tsx | 31 ++- packages/widgets/src/downloads/index.ts | 7 + pnpm-lock.yaml | 9 + 19 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 packages/integrations/src/download-client/aria2/aria2-integration.ts create mode 100644 packages/integrations/src/download-client/aria2/aria2-types.ts create mode 100644 packages/integrations/test/aria2.spec.ts diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 8e23e5127..9c0b78b21 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -49,6 +49,12 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", category: ["downloadClient", "torrent"], }, + aria2: { + name: "Aria2", + secretKinds: [[], ["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", + category: ["downloadClient", "torrent", "miscellaneous"], + }, sonarr: { name: "Sonarr", secretKinds: [["apiKey"]], @@ -211,6 +217,7 @@ export type IntegrationCategory = | "downloadClient" | "usenet" | "torrent" + | "miscellaneous" | "smartHomeServer" | "indexerManager" | "healthMonitoring" diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 5f2e5b6f3..bb1bdfe74 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -36,6 +36,7 @@ "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", "@jellyfin/sdk": "^0.11.0", + "maria2": "^0.4.0", "node-ical": "^0.20.1", "proxmox-api": "1.1.1", "tsdav": "^2.1.3", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 90467c71c..f9aec19a0 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -5,6 +5,7 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; import { DashDotIntegration } from "../dashdot/dashdot-integration"; +import { Aria2Integration } from "../download-client/aria2/aria2-integration"; import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; @@ -78,6 +79,7 @@ export const integrationCreators = { qBittorrent: QBitTorrentIntegration, deluge: DelugeIntegration, transmission: TransmissionIntegration, + aria2: Aria2Integration, jellyseerr: JellyseerrIntegration, overseerr: OverseerrIntegration, prowlarr: ProwlarrIntegration, diff --git a/packages/integrations/src/download-client/aria2/aria2-integration.ts b/packages/integrations/src/download-client/aria2/aria2-integration.ts new file mode 100644 index 000000000..b0da1b399 --- /dev/null +++ b/packages/integrations/src/download-client/aria2/aria2-integration.ts @@ -0,0 +1,180 @@ +import path from "path"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; + +import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { Aria2Download, Aria2GetClient } from "./aria2-types"; + +export class Aria2Integration extends DownloadClientIntegration { + public async getClientJobsAndStatusAsync(): Promise { + const client = this.getClient(); + const keys: (keyof Aria2Download)[] = [ + "bittorrent", + "uploadLength", + "uploadSpeed", + "downloadSpeed", + "totalLength", + "completedLength", + "files", + "status", + "gid", + ]; + const [activeDownloads, waitingDownloads, stoppedDownloads, globalStats] = await Promise.all([ + client.tellActive(), + client.tellWaiting(0, 1000, keys), + client.tellStopped(0, 1000, keys), + client.getGlobalStat(), + ]); + + const downloads = [...activeDownloads, ...waitingDownloads, ...stoppedDownloads]; + const allPaused = downloads.every((download) => download.status === "paused"); + + return { + status: { + types: ["torrent", "miscellaneous"], + paused: allPaused, + rates: { + up: Number(globalStats.uploadSpeed), + down: Number(globalStats.downloadSpeed), + }, + }, + items: downloads.map((download, index) => { + const totalSize = Number(download.totalLength); + const completedSize = Number(download.completedLength); + const progress = totalSize > 0 ? completedSize / totalSize : 0; + + const itemName = download.bittorrent?.info?.name ?? path.basename(download.files[0]?.path ?? "Unknown"); + + return { + index, + id: download.gid, + name: itemName, + type: download.bittorrent ? "torrent" : "miscellaneous", + size: totalSize, + sent: Number(download.uploadLength), + downSpeed: Number(download.downloadSpeed), + upSpeed: Number(download.uploadSpeed), + time: this.calculateEta(completedSize, totalSize, Number(download.downloadSpeed)), + state: this.getState(download.status, Boolean(download.bittorrent)), + category: [], + progress, + }; + }), + } as DownloadClientJobsAndStatus; + } + public async pauseQueueAsync(): Promise { + const client = this.getClient(); + await client.pauseAll(); + } + public async pauseItemAsync(item: DownloadClientItem): Promise { + const client = this.getClient(); + await client.pause(item.id); + } + public async resumeQueueAsync(): Promise { + const client = this.getClient(); + await client.unpauseAll(); + } + public async resumeItemAsync(item: DownloadClientItem): Promise { + const client = this.getClient(); + await client.unpause(item.id); + } + public async deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise { + const client = this.getClient(); + // Note: Remove download file is not support by aria2, replace with forceremove + + if (item.state in ["downloading", "leeching", "paused"]) { + await (fromDisk ? client.remove(item.id) : client.forceRemove(item.id)); + } else { + await client.removeDownloadResult(item.id); + } + } + + public async testConnectionAsync(): Promise { + const client = this.getClient(); + await client.getVersion(); + } + + private getClient() { + const url = this.url("/jsonrpc"); + + return new Proxy( + {}, + { + get: (target, method: keyof Aria2GetClient) => { + return async (...args: Parameters) => { + let params = [...args]; + if (this.hasSecretValue("apiKey")) { + params = [`token:${this.getSecretValue("apiKey")}`, ...params]; + } + const body = JSON.stringify({ + jsonrpc: "2.0", + id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request + method: `aria2.${method}`, + params, + }); + return await fetchWithTrustedCertificatesAsync(url, { method: "POST", body }) + .then(async (response) => { + const responseBody = (await response.json()) as { result: ReturnType }; + + if (!response.ok) { + throw new Error(response.statusText); + } + return responseBody.result; + }) + .catch((error) => { + if (error instanceof Error) { + throw new Error(error.message); + } else { + throw new Error("Error communicating with Aria2"); + } + }); + }; + }, + }, + ) as Aria2GetClient; + } + + private getState(aria2Status: Aria2Download["status"], isTorrent: boolean): DownloadClientItem["state"] { + return isTorrent ? this.getTorrentState(aria2Status) : this.getNonTorrentState(aria2Status); + } + private getNonTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] { + switch (aria2Status) { + case "active": + return "downloading"; + case "waiting": + return "queued"; + case "paused": + return "paused"; + case "complete": + return "completed"; + case "error": + return "failed"; + case "removed": + default: + return "unknown"; + } + } + private getTorrentState(aria2Status: Aria2Download["status"]): DownloadClientItem["state"] { + switch (aria2Status) { + case "active": + return "leeching"; + case "waiting": + return "queued"; + case "paused": + return "paused"; + case "complete": + return "completed"; + case "error": + return "failed"; + case "removed": + default: + return "unknown"; + } + } + private calculateEta(completed: number, total: number, speed: number): number { + if (speed === 0 || completed >= total) return 0; + return Math.floor((total - completed) / speed) * 1000; // Convert to milliseconds + } +} diff --git a/packages/integrations/src/download-client/aria2/aria2-types.ts b/packages/integrations/src/download-client/aria2/aria2-types.ts new file mode 100644 index 000000000..ec8354a75 --- /dev/null +++ b/packages/integrations/src/download-client/aria2/aria2-types.ts @@ -0,0 +1,80 @@ +export interface Aria2GetClient { + getVersion: Aria2VoidFunc; + getGlobalStat: Aria2VoidFunc; + + tellActive: Aria2VoidFunc; + tellWaiting: Aria2VoidFunc; + tellStopped: Aria2VoidFunc; + tellStatus: Aria2GidFunc; + + pause: Aria2GidFunc; + pauseAll: Aria2VoidFunc<"OK">; + unpause: Aria2GidFunc; + unpauseAll: Aria2VoidFunc<"OK">; + remove: Aria2GidFunc; + forceRemove: Aria2GidFunc; + removeDownloadResult: Aria2GidFunc<"OK">; +} +type AriaGID = string; + +type Aria2GidFunc = (gid: string, ...args: T) => Promise; +type Aria2VoidFunc = (...args: T) => Promise; + +type Aria2TellStatusListParams = [offset: number, num: number, keys?: [keyof Aria2Download] | (keyof Aria2Download)[]]; + +export interface Aria2GetVersion { + enabledFeatures: string[]; + version: string; +} +export interface Aria2GetGlobalStat { + downloadSpeed: string; + uploadSpeed: string; + numActive: string; + numWaiting: string; + numStopped: string; + numStoppedTotal: string; +} +export interface Aria2Download { + gid: AriaGID; + status: "active" | "waiting" | "paused" | "error" | "complete" | "removed"; + totalLength: string; + completedLength: string; + uploadLength: string; + bitfield: string; + downloadSpeed: string; + uploadSpeed: string; + infoHash?: string; + numSeeders?: string; + seeder?: "true" | "false"; + pieceLength: string; + numPieces: string; + connections: string; + errorCode?: string; + errorMessage?: string; + followedBy?: AriaGID[]; + following?: AriaGID; + belongsTo?: AriaGID; + dir: string; + files: { + index: number; + path: string; + length: string; + completedLength: string; + selected: "true" | "false"; + uris: { + status: "waiting" | "used"; + uri: string; + }[]; + }[]; + bittorrent?: { + announceList: string[]; + comment?: string | { "utf-8": string }; + creationDate?: number; + mode?: "single" | "multi"; + info?: { + name: string | { "utf-8": string }; + }; + verifiedLength?: number; + verifyIntegrityPending?: boolean; + }; +} diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts index 80c04b180..a0011e5bb 100644 --- a/packages/integrations/src/download-client/deluge/deluge-integration.ts +++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts @@ -32,7 +32,7 @@ export class DelugeIntegration extends DownloadClientIntegration { down: Math.floor(download_rate), up: Math.floor(upload_rate), }, - type, + types: [type], }; const items = torrents.map((torrent): DownloadClientItem => { const state = DelugeIntegration.getTorrentState(torrent.state); diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts index f09135348..711f438d4 100644 --- a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -21,7 +21,7 @@ export class NzbGetIntegration extends DownloadClientIntegration { const status: DownloadClientStatus = { paused: nzbGetStatus.DownloadPaused, rates: { down: nzbGetStatus.DownloadRate }, - type, + types: [type], }; const items = queue .map((file): DownloadClientItem => { diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts index e15acab5e..fb3234acb 100644 --- a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -24,7 +24,7 @@ export class QBitTorrentIntegration extends DownloadClientIntegration { ); const paused = torrents.find(({ state }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined; - const status: DownloadClientStatus = { paused, rates, type }; + const status: DownloadClientStatus = { paused, rates, types: [type] }; const items = torrents.map((torrent): DownloadClientItem => { const state = QBitTorrentIntegration.getTorrentState(torrent.state); return { diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts index 6da299198..19c84fce3 100644 --- a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -24,7 +24,7 @@ export class SabnzbdIntegration extends DownloadClientIntegration { const status: DownloadClientStatus = { paused: queue.paused, rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps () - type, + types: [type], }; const items = queue.slots .map((slot): DownloadClientItem => { diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts index eefc01cde..4750238ef 100644 --- a/packages/integrations/src/download-client/transmission/transmission-integration.ts +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -24,7 +24,7 @@ export class TransmissionIntegration extends DownloadClientIntegration { ); const paused = torrents.find(({ status }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined; - const status: DownloadClientStatus = { paused, rates, type }; + const status: DownloadClientStatus = { paused, rates, types: [type] }; const items = torrents.map((torrent): DownloadClientItem => { const state = TransmissionIntegration.getTorrentState(torrent.status); return { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 23f24b712..d0999bee1 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -5,6 +5,7 @@ export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; +export { Aria2Integration } from "./download-client/aria2/aria2-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; diff --git a/packages/integrations/src/interfaces/downloads/download-client-items.ts b/packages/integrations/src/interfaces/downloads/download-client-items.ts index 3d5195b4b..69ebfe88b 100644 --- a/packages/integrations/src/interfaces/downloads/download-client-items.ts +++ b/packages/integrations/src/interfaces/downloads/download-client-items.ts @@ -20,8 +20,8 @@ export const downloadClientItemSchema = z.object({ index: z.number(), /** Filename */ name: z.string(), - /** Torrent/Usenet identifier */ - type: z.enum(["torrent", "usenet"]), + /** Download Client identifier */ + type: z.enum(["torrent", "usenet", "miscellaneous"]), /** Item size in Bytes */ size: z.number(), /** Total uploaded in Bytes, only required for Torrent items */ diff --git a/packages/integrations/src/interfaces/downloads/download-client-status.ts b/packages/integrations/src/interfaces/downloads/download-client-status.ts index 37fb290b6..d866c93b1 100644 --- a/packages/integrations/src/interfaces/downloads/download-client-status.ts +++ b/packages/integrations/src/interfaces/downloads/download-client-status.ts @@ -8,7 +8,7 @@ export interface DownloadClientStatus { down: number; up?: number; }; - type: "usenet" | "torrent"; + types: ("usenet" | "torrent" | "miscellaneous")[]; } export interface ExtendedClientStatus { integration: Pick & { updatedAt: Date }; diff --git a/packages/integrations/test/aria2.spec.ts b/packages/integrations/test/aria2.spec.ts new file mode 100644 index 000000000..1cde98723 --- /dev/null +++ b/packages/integrations/test/aria2.spec.ts @@ -0,0 +1,153 @@ +import type { StartedTestContainer } from "testcontainers"; +import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { Aria2Integration } from "../src"; + +const API_KEY = "ARIA2_API_KEY"; +const IMAGE_NAME = "hurlenko/aria2-ariang:latest"; + +describe("Aria2 integration", () => { + beforeAll(async () => { + const containerRuntimeClient = await getContainerRuntimeClient(); + await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME)); + }, 100_000); + + test("Test connection should work", async () => { + // Arrange + const startedContainer = await createAria2Container().start(); + const aria2Integration = createAria2Intergration(startedContainer, API_KEY); + + // Act + const actAsync = async () => await aria2Integration.testConnectionAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + + // Cleanup + await startedContainer.stop(); + }, 30_000); + + test("pauseQueueAsync should work", async () => { + // Arrange + const startedContainer = await createAria2Container().start(); + const aria2Integration = createAria2Intergration(startedContainer, API_KEY); + + // Acts + const actAsync = async () => await aria2Integration.pauseQueueAsync(); + const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } }); + + // Cleanup + await startedContainer.stop(); + }, 30_000); // Timeout of 30 seconds + + test("Items should be empty", async () => { + // Arrange + const startedContainer = await createAria2Container().start(); + const aria2Integration = createAria2Intergration(startedContainer, API_KEY); + + // Act + const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ + items: [], + }); + + // Cleanup + await startedContainer.stop(); + }, 30_000); // Timeout of 30 seconds + + test("1 Items should exist after adding one", async () => { + // Arrange + const startedContainer = await createAria2Container().start(); + const aria2Integration = createAria2Intergration(startedContainer, API_KEY); + await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration); + + // Act + const getAsync = async () => await aria2Integration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + expect((await getAsync()).items).toHaveLength(1); + + // Cleanup + await startedContainer.stop(); + }, 30_000); // Timeout of 30 seconds + + test("Delete item should result in empty items", async () => { + // Arrange + const startedContainer = await createAria2Container().start(); + const aria2Integration = createAria2Intergration(startedContainer, API_KEY); + const item = await aria2AddItemAsync(startedContainer, API_KEY, aria2Integration); + + // Act + const actAsync = async () => await aria2Integration.deleteItemAsync(item, true); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + // 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)); + const result = await aria2Integration.getClientJobsAndStatusAsync(); + expect(result.items).toHaveLength(0); + + // Cleanup + await startedContainer.stop(); + }, 30_000); // Timeout of 30 seconds +}); + +const createAria2Container = () => { + return new GenericContainer(IMAGE_NAME) + .withExposedPorts(8080) + .withEnvironment({ + PUID: "1000", + PGID: "1000", + ARIA2RPCPORT: "443", + RPC_SECRET: API_KEY, + }) + .withWaitStrategy(Wait.forLogMessage("listening on TCP port")); +}; + +const createAria2Intergration = (container: StartedTestContainer, apikey: string) => { + return new Aria2Integration({ + id: "1", + decryptedSecrets: [ + { + kind: "apiKey", + value: apikey, + }, + ], + name: "Aria2", + url: `http://${container.getHost()}:${container.getMappedPort(8080)}`, + }); +}; + +const aria2AddItemAsync = async (container: StartedTestContainer, apiKey: string, integration: Aria2Integration) => { + await fetch(`http://${container.getHost()}:${container.getMappedPort(8080)}/jsonrpc`, { + method: "POST", + body: JSON.stringify({ + jsonrpc: "2.0", + id: btoa(["Homarr", Date.now().toString(), Math.random()].join(".")), // unique id per request + method: "aria2.addUri", + params: [`token:${apiKey}`, ["https://google.com"]], + }), + }); + + await delay(3000); + + const { + items: [item], + } = await integration.getClientJobsAndStatusAsync(); + + if (!item) { + throw new Error("No item found"); + } + + return item; +}; +const delay = (microseconds: number) => new Promise((resolve) => setTimeout(resolve, microseconds)); diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 643ecb99d..f35306ad5 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -76,6 +76,7 @@ const optionMapping: OptionMapping = { defaultSort: () => "type", descendingDefaultSort: () => false, showCompletedUsenet: () => true, + showCompletedHttp: () => true, }, weather: { forecastDayCount: (oldOptions) => oldOptions.forecastDays, diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 5ea1ce039..e4c50bf11 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1786,6 +1786,9 @@ "showCompletedTorrent": { "label": "Show torrent entries marked as completed" }, + "showCompletedHttp": { + "label": "Show Miscellaneous entries marked as completed" + }, "activeTorrentThreshold": { "label": "Hide completed torrent under this threshold (in kiB/s)" }, diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 588cdf608..d8139641e 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -171,7 +171,8 @@ export default function DownloadClientsWidget({ options.showCompletedTorrent && (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || progress !== 1)) || - (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)), + (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)) || + (type === "miscellaneous" && ((progress === 1 && options.showCompletedHttp) || progress !== 1)), ) //Filter following user quick setting .filter( @@ -189,7 +190,7 @@ export default function DownloadClientsWidget({ ...item, category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, received, - ratio: item.sent !== undefined ? item.sent / received : undefined, + ratio: item.sent !== undefined ? item.sent / (received || 1) : undefined, //Only add if permission to use mutations actions: integrationsWithInteractions.includes(pair.integration.id) ? { @@ -215,6 +216,7 @@ export default function DownloadClientsWidget({ options.filterIsWhitelist, options.showCompletedTorrent, options.showCompletedUsenet, + options.showCompletedHttp, quickFilters, ], ); @@ -232,7 +234,7 @@ export default function DownloadClientsWidget({ .filter( ({ category }) => !options.applyFilterToRatio || - data.status.type !== "torrent" || + !data.status.types.includes("torrent") || options.filterIsWhitelist === options.categoryFilter.some((filter) => (Array.isArray(category) ? category : [category]).includes(filter), @@ -258,7 +260,7 @@ export default function DownloadClientsWidget({ }) .sort( ({ status: statusA }, { status: statusB }) => - (statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity), + (statusA?.types.length ?? Infinity) - (statusB?.types.length ?? Infinity), ), [ currentItems, @@ -272,8 +274,10 @@ export default function DownloadClientsWidget({ //Check existing types between torrents and usenet const integrationTypes: ExtendedDownloadClientItem["type"][] = []; + if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent"); if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet"); + if (data.some(({ type }) => type === "miscellaneous")) integrationTypes.push("miscellaneous"); //Set the visibility of columns depending on widget settings and available data/integrations. const columnVisibility: MRT_VisibilityState = { @@ -677,15 +681,22 @@ const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalPr - + {item.type !== "miscellaneous" && ( + + )} + - + + {item.type !== "miscellaneous" && ( + + )} + - + {item.type !== "miscellaneous" && } diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index e4b8831b6..5323c20df 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -63,6 +63,9 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads showCompletedTorrent: factory.switch({ defaultValue: true, }), + showCompletedHttp: factory.switch({ + defaultValue: true, + }), activeTorrentThreshold: factory.number({ //in KiB/s validate: z.number().min(0), @@ -95,6 +98,10 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads shouldHide: (_, integrationKinds) => !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)), }, + showCompletedHttp: { + shouldHide: (_, integrationKinds) => + !getIntegrationKindsByCategory("miscellaneous").some((kinds) => integrationKinds.includes(kinds)), + }, activeTorrentThreshold: { shouldHide: (_, integrationKinds) => !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91f338f35..908fbd657 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1306,6 +1306,9 @@ importers: '@jellyfin/sdk': specifier: ^0.11.0 version: 0.11.0(axios@1.8.4) + maria2: + specifier: ^0.4.0 + version: 0.4.0 node-ical: specifier: ^0.20.1 version: 0.20.1 @@ -7779,6 +7782,10 @@ packages: react: '>=18.0' react-dom: '>=18.0' + maria2@0.4.0: + resolution: {integrity: sha512-jQ9yezKDaSeiy7SZRA8zMAYkFyfx3yoeB/lhcOSQsoGf4YyVaPDopP+dEU6HWNJwK2Oo/rmgqTb5Na1gJcLtrg==} + engines: {node: '>= 22.5.0'} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -16649,6 +16656,8 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + maria2@0.4.0: {} + markdown-it@14.1.0: dependencies: argparse: 2.0.1