diff --git a/.gitignore b/.gitignore index f7d87aa53..1053491af 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,7 @@ db.sqlite *.log apps/tasks/tasks.cjs -apps/websocket/wssServer.cjs \ No newline at end of file +apps/websocket/wssServer.cjs + +#personal backgrounds +apps/nextjs/public/images/background.png \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 90148c6f2..6735ab20c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,10 +12,12 @@ "cqmin", "homarr", "jellyfin", + "mantine", + "Sabnzbd", + "Sonarr", "superjson", "trpc", - "Umami", - "Sonarr" + "Umami" ], "i18n-ally.dirStructure": "auto", "i18n-ally.enabledFrameworks": ["next-international"], diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx index d38c868a2..0d7f1b880 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx @@ -86,6 +86,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie }); }, [dimensions, openPreviewDimensionsModal]); + const updateOptions = ({ newOptions }: { newOptions: Record }) => + setState({ ...state, options: { ...state.options, newOptions } }); + return ( <> = 96 ? undefined : 4}> @@ -105,6 +108,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie isEditMode={editMode} boardId={undefined} itemId={undefined} + setOptions={updateOptions} /> )} diff --git a/apps/nextjs/src/components/board/sections/content.tsx b/apps/nextjs/src/components/board/sections/content.tsx index d68e35df0..84688f3c5 100644 --- a/apps/nextjs/src/components/board/sections/content.tsx +++ b/apps/nextjs/src/components/board/sections/content.tsx @@ -99,7 +99,10 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => { const serverData = useServerDataFor(item.id); const Comp = loadWidgetDynamic(item.kind); const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); + const { updateItemOptions } = useItemActions(); const newItem = { ...item, options }; + const updateOptions = ({ newOptions }: { newOptions: Record }) => + updateItemOptions({ itemId: item.id, newOptions }); if (!serverData?.isReady) return null; @@ -124,6 +127,7 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => { isEditMode={isEditMode} boardId={board.id} itemId={item.id} + setOptions={updateOptions} {...dimensions} /> diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css index aacc4803e..af488c201 100644 --- a/apps/nextjs/src/components/board/sections/item.module.css +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -1,10 +1,12 @@ .itemCard { @mixin dark { - background-color: rgba(46, 46, 46, var(--opacity)); - border-color: rgba(66, 66, 66, var(--opacity)); + --background-color: rgb(from var(--mantine-color-dark-6) r g b / var(--opacity)); + --border-color: rgb(from var(--mantine-color-dark-4) r g b / var(--opacity)); } @mixin light { - background-color: rgba(255, 255, 255, var(--opacity)); - border-color: rgba(222, 226, 230, var(--opacity)); + --background-color: rgb(from var(--mantine-color-white) r g b / var(--opacity)); + --border-color: rgb(from var(--mantine-color-gray-3) r g b / var(--opacity)); } + background-color: var(--background-color); + border-color: var(--border-color); } diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts new file mode 100644 index 000000000..3f8c3814f --- /dev/null +++ b/packages/api/src/router/widgets/downloads.ts @@ -0,0 +1,139 @@ +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; + +import type { IntegrationKind } from "@homarr/definitions"; +import type { + DownloadClientData, + DownloadClientIntegration, + IntegrationInput, + SanitizedIntegration, +} from "@homarr/integrations"; +import { integrationCreatorByKind } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; + +import type { DownloadClientItem } from "../../../../integrations/src/interfaces/downloads/download-client-items"; +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const downloadsRouter = createTRPCRouter({ + getData: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .query(async ({ ctx }) => { + return await Promise.all( + ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { + const channel = createItemAndIntegrationChannel("downloads", integration.id); + const data = await channel.getAsync(); + return { + integration: integration as SanitizedIntegration, + data: data?.data ?? ({} as DownloadClientData), + }; + }), + ); + }), + subscribeToData: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .subscription(({ ctx }) => { + return observable<{ integration: SanitizedIntegration; data: DownloadClientData }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const channel = createItemAndIntegrationChannel("downloads", integration.id); + const unsubscribe = channel.subscribe((sessions) => { + emit.next({ + integration: integration as SanitizedIntegration, + data: sessions, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + pause: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = getIntegrationInstance(integration.kind, integration); + await integrationInstance.pauseQueueAsync(); + }), + ); + }), + pauseItem: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .input(z.object({ item: z.any() satisfies z.ZodType })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = getIntegrationInstance(integration.kind, integration); + await integrationInstance.pauseItemAsync(input.item as DownloadClientItem); + }), + ); + }), + resume: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = getIntegrationInstance(integration.kind, integration); + await integrationInstance.resumeQueueAsync(); + }), + ); + }), + resumeItem: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .input(z.object({ item: z.any() satisfies z.ZodType })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = getIntegrationInstance(integration.kind, integration); + await integrationInstance.resumeItemAsync(input.item as DownloadClientItem); + }), + ); + }), + deleteItem: publicProcedure + .unstable_concat( + createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"), + ) + .input(z.object({ item: z.any() satisfies z.ZodType, fromDisk: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = getIntegrationInstance(integration.kind, integration); + await integrationInstance.deleteItemAsync(input.item as DownloadClientItem, input.fromDisk); + }), + ); + }), +}); + +function getIntegrationInstance(kind: IntegrationKind, integration: IntegrationInput): DownloadClientIntegration { + switch (kind) { + case "sabNzbd": + case "nzbGet": + case "qBittorrent": + case "deluge": + case "transmission": + return integrationCreatorByKind(kind, integration) as DownloadClientIntegration; + default: + throw new TRPCError({ + code: "BAD_REQUEST", + }); + } +} diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 9e32075cb..f1e5152cc 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -2,11 +2,11 @@ import { createTRPCRouter } from "../../trpc"; import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; +import { downloadsRouter } from "./downloads"; import { mediaServerRouter } from "./media-server"; import { notebookRouter } from "./notebook"; import { smartHomeRouter } from "./smart-home"; import { weatherRouter } from "./weather"; -import {usenetDownloadsRouter} from "./usenet-downloads"; export const widgetRouter = createTRPCRouter({ notebook: notebookRouter, @@ -16,5 +16,5 @@ export const widgetRouter = createTRPCRouter({ smartHome: smartHomeRouter, mediaServer: mediaServerRouter, calendar: calendarRouter, - usenetDownloads: usenetDownloadsRouter + downloads: downloadsRouter, }); diff --git a/packages/api/src/router/widgets/usenet-downloads.ts b/packages/api/src/router/widgets/usenet-downloads.ts deleted file mode 100644 index fb2da4c90..000000000 --- a/packages/api/src/router/widgets/usenet-downloads.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {createTRPCRouter, publicProcedure} from "../../trpc"; -import {createManyIntegrationMiddleware} from "../../middlewares/integration"; -import {createItemAndIntegrationChannel} from "@homarr/redis"; -import type { UsenetQueueItem} from "@homarr/integrations"; -import {NzbGetIntegration, SabnzbdIntegration} from "@homarr/integrations"; -import {observable} from "@trpc/server/observable"; -import {TRPCError} from "@trpc/server"; - -export const usenetDownloadsRouter = createTRPCRouter({ - getQueue: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet")) - .query(async ({ctx}) => { - return await Promise.all(ctx.integrations.map(async (integration) => { - const channel = createItemAndIntegrationChannel("usenet-downloads", integration.id); - const data = await channel.getAsync(); - return { - integrationId: integration.id, - queue: data?.data ?? [] - } - })) - }), - subscribeToQueue: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet")) - .subscription(({ctx}) => { - return observable<{ integrationId: string; data: UsenetQueueItem[] }>((emit) => { - const unsubscribes: (() => void)[] = []; - for (const integration of ctx.integrations) { - const channel = createItemAndIntegrationChannel("usenet-downloads", integration.id); - const unsubscribe = channel.subscribe((sessions) => { - emit.next({ - integrationId: integration.id, - data: sessions, - }); - }); - unsubscribes.push(unsubscribe); - } - return () => { - unsubscribes.forEach((unsubscribe) => { - unsubscribe(); - }); - }; - }); - }), - resume: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet")) - .mutation(async ({ctx}) => { - await Promise.all(ctx.integrations.map(async (integration) => { - switch (integration.kind) { - case "sabNzbd": - await new SabnzbdIntegration(integration).resumeQueueAsync(); - break; - case "nzbGet": - await new NzbGetIntegration(integration).resumeQueueAsync(); - break; - default: - throw new TRPCError({ - code: 'BAD_REQUEST' - }) - } - })); - }), - pause: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet")) - .mutation(async ({ctx}) => { - await Promise.all(ctx.integrations.map(async (integration) => { - switch (integration.kind) { - case "sabNzbd": - await new SabnzbdIntegration(integration).pauseQueueAsync(); - break; - case "nzbGet": - await new NzbGetIntegration().pauseQueueAsync(); - break; - default: - throw new TRPCError({ - code: 'BAD_REQUEST' - }) - } - })); - }) -}); diff --git a/packages/common/src/number.ts b/packages/common/src/number.ts index 24738850d..66e4daab8 100644 --- a/packages/common/src/number.ts +++ b/packages/common/src/number.ts @@ -7,6 +7,9 @@ const ranges = [ { divider: 1e3, suffix: "k" }, ]; +//64bit limit for Number stops at EiB +const siRanges = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + export const formatNumber = (value: number, decimalPlaces: number) => { for (const range of ranges) { if (value < range.divider) continue; @@ -19,3 +22,19 @@ export const formatNumber = (value: number, decimalPlaces: number) => { export const randomInt = (min: number, max: number) => { return Math.floor(Math.random() * (max - min + 1) + min); }; + +/** + * Number of bytes to si format. (division by 1024) + * Does not accept floats, size in bytes are should be integer. + */ +export const humanFileSize = (size: number) => { + if (!Number.isInteger(size)) return NaN; + let count = 0; + while (true) { + const tempSize = size / Math.pow(1024, count); + if (tempSize < 1024 || count === siRanges.length - 1) { + return tempSize.toFixed(Math.min(count, 1)) + siRanges[count]; + } + count++; + } +}; diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 2ad2bb698..f16acdafd 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -1,11 +1,11 @@ import { analyticsJob } from "./jobs/analytics"; import { iconsUpdaterJob } from "./jobs/icons-updater"; +import { downloadsJob } from "./jobs/integrations/downloads"; import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; import { mediaServerJob } from "./jobs/integrations/media-server"; import { pingJob } from "./jobs/ping"; import { createCronJobGroup } from "./lib"; -import { usenetDownloadsJob } from "./jobs/integrations/usenet-downloads"; export const jobGroup = createCronJobGroup({ analytics: analyticsJob, @@ -14,7 +14,7 @@ export const jobGroup = createCronJobGroup({ smartHomeEntityState: smartHomeEntityStateJob, mediaServer: mediaServerJob, mediaOrganizer: mediaOrganizerJob, - usenetDownloads: usenetDownloadsJob + downloads: downloadsJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/downloads.ts b/packages/cron-jobs/src/jobs/integrations/downloads.ts new file mode 100644 index 000000000..1ee9cad72 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/downloads.ts @@ -0,0 +1,61 @@ +import { decryptSecret } from "@homarr/common"; +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; +import type { DownloadClientData, DownloadClientIntegration, IntegrationInput } from "@homarr/integrations"; +import { integrationCreatorByKind } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import type { IntegrationKind } from "../../../../definitions/src"; +import { createCronJob } from "../../lib"; + +export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => { + const itemsForIntegration = await db.query.items.findMany({ + where: eq(items.kind, "downloads"), + with: { + integrations: { + with: { + integration: { + with: { + secrets: { + columns: { + kind: true, + value: true, + }, + }, + }, + }, + }, + }, + }, + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const integration of itemForIntegration.integrations) { + const integrationWithDecryptedSecrets = { + ...integration.integration, + decryptedSecrets: integration.integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }; + const integrationInstance = getIntegrationInstance(integration.integration.kind, integrationWithDecryptedSecrets); + const data = await integrationInstance.getClientDataAsync(); + const channel = createItemAndIntegrationChannel("downloads", integration.integrationId); + await channel.publishAndUpdateLastStateAsync(data); + } + } +}); + +function getIntegrationInstance(kind: IntegrationKind, integration: IntegrationInput): DownloadClientIntegration { + switch (kind) { + case "sabNzbd": + case "nzbGet": + case "qBittorrent": + case "deluge": + case "transmission": + return integrationCreatorByKind(kind, integration) as DownloadClientIntegration; + default: + throw new Error("BAD_REQUEST"); + } +} diff --git a/packages/cron-jobs/src/jobs/integrations/usenet-downloads.ts b/packages/cron-jobs/src/jobs/integrations/usenet-downloads.ts deleted file mode 100644 index 3100ed66f..000000000 --- a/packages/cron-jobs/src/jobs/integrations/usenet-downloads.ts +++ /dev/null @@ -1,48 +0,0 @@ -import {createCronJob} from "../../lib"; -import {EVERY_5_SECONDS} from "@homarr/cron-jobs-core/expressions"; -import {db, eq,} from "@homarr/db"; -import {items} from "@homarr/db/schema/sqlite"; -import type { UsenetQueueItem} from "@homarr/integrations"; -import {NzbGetIntegration, SabnzbdIntegration} from "@homarr/integrations"; -import {decryptSecret} from "@homarr/common"; -import {createItemAndIntegrationChannel} from "@homarr/redis"; - -export const usenetDownloadsJob = createCronJob("usenet-downloads", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await db.query.items.findMany({ - where: eq(items.kind, "usenet-downloads"), - with: { - integrations: { - with: { - integration: { - with: { - secrets: { - columns: { - kind: true, - value: true, - }, - }, - }, - }, - }, - }, - }, - }); - - for (const itemForIntegration of itemsForIntegration) { - for (const integration of itemForIntegration.integrations) { - const integrationWithDecryptedSecrets = { - ...integration.integration, - decryptedSecrets: integration.integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }; - const integrationInstance = integration.integration.kind === "sabNzbd" ? - new SabnzbdIntegration(integrationWithDecryptedSecrets) : - new NzbGetIntegration(integrationWithDecryptedSecrets); - const queue = await integrationInstance.getCurrentQueueAsync(); - const channel = createItemAndIntegrationChannel("usenet-downloads", integration.integrationId); - await channel.publishAndUpdateLastStateAsync(queue); - } - } -}); diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 7564a6ae8..a461a3686 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -13,13 +13,13 @@ export const integrationDefs = { name: "SABnzbd", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png", - category: ["useNetClient"], + category: ["downloadClient"], }, nzbGet: { name: "NZBGet", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png", - category: ["useNetClient"], + category: ["downloadClient"], }, deluge: { name: "Deluge", @@ -137,5 +137,4 @@ export type IntegrationCategory = | "mediaSearch" | "mediaRequest" | "downloadClient" - | "useNetClient" | "smartHomeServer"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 78665b71f..50ecd00e6 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -10,6 +10,6 @@ export const widgetKinds = [ "smartHome-executeAutomation", "mediaServer", "calendar", - "usenet-downloads" + "downloads", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index d30ade124..d23deceeb 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -23,13 +23,16 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@ctrl/deluge": "^6.1.0", + "@ctrl/qbittorrent": "^8.2.0", + "@ctrl/transmission": "^6.1.0", "@homarr/common": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", - "@jellyfin/sdk": "^0.10.0", - "sabnzbd-api": "^1.5.0" + "@jc21/nzbget-jsonrpc-api": "^1.0.0", + "@jellyfin/sdk": "^0.10.0" }, "devDependencies": { "@homarr/eslint-config": "workspace:^0.2.0", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index ea37f2473..697187f22 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,12 +1,15 @@ import type { IntegrationKind } from "@homarr/definitions"; +import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; +import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; +import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; +import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; +import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; import type { IntegrationInput } from "./integration"; -import {SabnzbdIntegration} from "../sabnzbd/sabnzbd-integration"; -import {NzbGetIntegration} from "../nzbget/nzbget-integration"; export const integrationCreatorByKind = (kind: IntegrationKind, integration: IntegrationInput) => { switch (kind) { @@ -22,6 +25,12 @@ export const integrationCreatorByKind = (kind: IntegrationKind, integration: Int return new SabnzbdIntegration(integration); case "nzbGet": return new NzbGetIntegration(integration); + case "qBittorrent": + return new QBitTorrentIntegration(integration); + case "deluge": + return new DelugeIntegration(integration); + case "transmission": + return new TransmissionIntegration(integration); default: throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`); } diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index 69b346e23..1d8014a99 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -1,5 +1,5 @@ import { extractErrorMessage } from "@homarr/common"; -import type { IntegrationSecretKind } from "@homarr/definitions"; +import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { logger } from "@homarr/log"; import type { TranslationObject } from "@homarr/translation"; import { z } from "@homarr/validation"; @@ -18,6 +18,10 @@ export interface IntegrationInput { decryptedSecrets: IntegrationSecret[]; } +export interface SanitizedIntegration extends Omit { + kind: IntegrationKind; +} + export abstract class Integration { constructor(protected integration: IntegrationInput) {} diff --git a/packages/integrations/src/download-client/deluge/deluge-integration.ts b/packages/integrations/src/download-client/deluge/deluge-integration.ts new file mode 100644 index 000000000..ea3961fde --- /dev/null +++ b/packages/integrations/src/download-client/deluge/deluge-integration.ts @@ -0,0 +1,116 @@ +import { Deluge } from "@ctrl/deluge"; +import dayjs from "dayjs"; + +import type { DownloadClientData } 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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; + +export class DelugeIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + const client = this.getClient(); + await client.login(); + } + + public async getClientDataAsync(): Promise { + const type = "torrent"; + const client = this.getClient(); + const { + stats: { download_rate, upload_rate }, + torrents: rawTorrents, + } = (await client.listTorrents(["completed_time"])).result; + const torrents = Object.entries(rawTorrents).map(([id, torrent]) => ({ + ...(torrent as { completed_time: number } & typeof torrent), + id, + })); + const paused = torrents.find(({ state }) => this.getTorrentState(state) !== "paused") === undefined; + const status: DownloadClientStatus = { + paused, + rates: { + down: Math.floor(download_rate), + up: Math.floor(upload_rate), + }, + type, + }; + const items = torrents.map((torrent): DownloadClientItem => { + const state = this.getTorrentState(torrent.state); + return { + type, + id: torrent.id, + index: torrent.queue, + name: torrent.name, + size: torrent.total_wanted, + sent: torrent.total_uploaded, + downSpeed: torrent.progress !== 100 ? torrent.download_payload_rate : undefined, + upSpeed: torrent.upload_payload_rate, + time: + torrent.progress === 100 + ? Math.min((torrent.completed_time - dayjs().unix()) * 1000, -1) + : Math.max(torrent.eta * 1000, 0), + added: torrent.time_added * 1000, + state, + progress: torrent.progress / 100, + category: torrent.label, + }; + }); + return { status, items }; + } + + public async pauseQueueAsync() { + const client = this.getClient(); + const store = (await client.listTorrents()).result.torrents; + await Promise.all( + Object.entries(store).map(async ([id]) => { + await this.getClient().pauseTorrent(id); + }), + ); + } + + public async pauseItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().pauseTorrent(id); + } + + public async resumeQueueAsync() { + const client = this.getClient(); + const store = (await client.listTorrents()).result.torrents; + await Promise.all( + Object.entries(store).map(async ([id]) => { + await this.getClient().resumeTorrent(id); + }), + ); + } + + public async resumeItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().resumeTorrent(id); + } + + public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise { + await this.getClient().removeTorrent(id, fromDisk); + } + + private getClient() { + const baseUrl = new URL(this.integration.url).href; + return new Deluge({ + baseUrl, + password: this.getSecretValue("password"), + }); + } + + private getTorrentState(state: string): DownloadClientItem["state"] { + switch (state) { + case "Queued": + case "Checking": + case "Allocating": + case "Downloading": + return "leeching"; + case "Seeding": + return "seeding"; + case "Paused": + return "paused"; + case "Error": + case "Moving": + default: + return "unknown"; + } + } +} diff --git a/packages/integrations/src/download-client/nzbget/nzbget-integration.ts b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts new file mode 100644 index 000000000..8b8d9a784 --- /dev/null +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -0,0 +1,122 @@ +import { Client as NzbGetClient } from "@jc21/nzbget-jsonrpc-api"; +import dayjs from "dayjs"; + +import type { DownloadClientData } 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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; + +export class NzbGetIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + const client = this.getClient(); + await client.status(); + } + + public async getClientDataAsync(): Promise { + const type = "usenet"; + const nzbGetClient = this.getClient(); + const queue = await nzbGetClient.listgroups(); + const history = await nzbGetClient.history(); + const nzbGetStatus = await nzbGetClient.status(); + const status: DownloadClientStatus = { + paused: nzbGetStatus.DownloadPaused, + rates: { down: nzbGetStatus.DownloadRate }, + type, + }; + const items = queue + .map((file): DownloadClientItem => { + const state = this.getNzbQueueState(file.Status); + return { + type, + id: file.NZBID.toString(), + index: file.MaxPriority, + name: file.NZBName, + size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32), + downSpeed: file.ActiveDownloads > 0 ? nzbGetStatus.DownloadRate : 0, + time: (file.RemainingSizeLo + file.RemainingSizeHi * Math.pow(2, 32)) / (nzbGetStatus.DownloadRate / 1000), + added: dayjs().valueOf() - file.DownloadTimeSec * 1000, + state, + progress: file.DownloadedSizeMB / file.FileSizeMB, + category: file.Category, + }; + }) + .concat( + history.map((file, index): DownloadClientItem => { + const state = this.getNzbHistoryState(file.ScriptStatus); + return { + type, + id: file.NZBID.toString(), + index, + name: file.Name, + size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32), + time: dayjs().valueOf() - file.HistoryTime * 1000, + added: (file.HistoryTime - file.DownloadTimeSec) * 1000, + state, + progress: 1, + category: file.Category, + }; + }), + ); + return { status, items }; + } + + public async pauseQueueAsync() { + await this.getClient().pausedownload(); + } + + public async pauseItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().editqueue("GroupPause", "", [Number(id)]); + } + + public async resumeQueueAsync() { + await this.getClient().resumedownload(); + } + + public async resumeItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().editqueue("GroupResume", "", [Number(id)]); + } + + public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise { + const client = this.getClient(); + if (fromDisk) { + const filesIds = (await client.listfiles(Number(id))).map((value) => value.ID); + await this.getClient().editqueue("FileDelete", "", filesIds); + } + if (progress !== 1) { + await client.editqueue("GroupFinalDelete", "", [Number(id)]); + } else { + await client.editqueue("HistoryFinalDelete", "", [Number(id)]); + } + } + + private getClient() { + const url = new URL(this.integration.url); + url.username = this.getSecretValue("username"); + url.password = this.getSecretValue("password"); + //url.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`; + url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; + return new NzbGetClient(url); + } + + private getNzbQueueState(status: string): DownloadClientItem["state"] { + switch (status) { + case "QUEUED": + return "queued"; + case "PAUSED": + return "paused"; + default: + return "downloading"; + } + } + + private getNzbHistoryState(status: string): DownloadClientItem["state"] { + switch (status) { + case "FAILURE": + return "failed"; + case "SUCCESS": + return "completed"; + default: + return "processing"; + } + } +} diff --git a/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts new file mode 100644 index 000000000..6d633abf1 --- /dev/null +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -0,0 +1,111 @@ +import { QBittorrent } from "@ctrl/qbittorrent"; +import dayjs from "dayjs"; + +import type { DownloadClientData } 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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; + +export class QBitTorrentIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + const client = this.getClient(); + await client.login(); + } + + public async getClientDataAsync(): Promise { + const type = "torrent"; + const client = this.getClient(); + const torrents = await client.listTorrents(); + const rates = torrents.reduce( + ({ down, up }, { dlspeed, upspeed }) => ({ down: down + dlspeed, up: up + upspeed }), + { down: 0, up: 0 }, + ); + const paused = torrents.find(({ state }) => this.getTorrentState(state) !== "paused") === undefined; + const status: DownloadClientStatus = { paused, rates, type }; + const items = torrents.map((torrent): DownloadClientItem => { + const state = this.getTorrentState(torrent.state); + return { + type, + id: torrent.hash, + index: torrent.priority, + name: torrent.name, + size: torrent.size, + sent: torrent.uploaded, + downSpeed: torrent.progress !== 1 ? torrent.dlspeed : undefined, + upSpeed: torrent.upspeed, + time: + torrent.progress === 1 + ? Math.min(torrent.completion_on * 1000 - dayjs().valueOf(), -1) + : torrent.eta === 8640000 + ? 0 + : Math.max(torrent.eta * 1000, 0), + added: torrent.added_on * 1000, + state, + progress: torrent.progress, + category: torrent.category, + }; + }); + return { status, items }; + } + + public async pauseQueueAsync() { + await this.getClient().pauseTorrent("all"); + } + + public async pauseItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().pauseTorrent(id); + } + + public async resumeQueueAsync() { + await this.getClient().resumeTorrent("all"); + } + + public async resumeItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().resumeTorrent(id); + } + + public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise { + await this.getClient().removeTorrent(id, fromDisk); + } + + private getClient() { + const baseUrl = new URL(this.integration.url).href; + return new QBittorrent({ + baseUrl, + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }); + } + + private getTorrentState(state: string): DownloadClientItem["state"] { + switch (state) { + case "allocating": + case "checkingDL": + case "downloading": + case "forcedDL": + case "forcedMetaDL": + case "metaDL": + case "queuedDL": + case "queuedForChecking": + return "leeching"; + case "checkingUP": + case "forcedUP": + case "queuedUP": + case "uploading": + case "stalledUP": + return "seeding"; + case "pausedDL": + case "pausedUP": + return "paused"; + case "stalledDL": + return "stalled"; + case "error": + case "checkingResumeData": + case "missingFiles": + case "moving": + case "unknown": + default: + return "unknown"; + } + } +} diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts new file mode 100644 index 000000000..5fefebc2c --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-integration.ts @@ -0,0 +1,149 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; + +import type { DownloadClientData } 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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; +import { historySchema, queueSchema } from "./sabnzbd-schema"; + +dayjs.extend(duration); + +export class SabnzbdIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + //This is the one call that uses the least amount of data while requiring the api key + await this.sabNzbApiCallAsync("translate", new URLSearchParams({ value: "ping" })); + } + + public async getClientDataAsync(): Promise { + const type = "usenet"; + const { queue } = await queueSchema.parseAsync(await this.sabNzbApiCallAsync("queue")); + const { history } = await historySchema.parseAsync(await this.sabNzbApiCallAsync("history")); + const status: DownloadClientStatus = { + paused: queue.paused, + rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps () + type, + }; + const items = queue.slots + .map((slot): DownloadClientItem => { + const state = this.getNzbQueueState(slot.status); + const times = slot.timeleft.split(":").reverse(); + const time = dayjs + .duration({ + seconds: Number(times[0] ?? 0), + minutes: Number(times[1] ?? 0), + hours: Number(times[2] ?? 0), + days: Number(times[3] ?? 0), + }) + .asMilliseconds(); + return { + type, + id: slot.nzo_id, + index: slot.index, + name: slot.filename, + size: Math.ceil(parseFloat(slot.mb) * 1024 * 1024), //Actually rounded MiB + downSpeed: slot.index > 0 ? 0 : status.rates.down, + time, + //added: 0, <- Only part from all integrations that is missing the timestamp (or from which it could be inferred) + state, + progress: parseFloat(slot.percentage) / 100, + category: slot.cat, + }; + }) + .concat( + history.slots.map((slot, index): DownloadClientItem => { + const state = this.getNzbHistoryState(slot.status); + return { + type, + id: slot.nzo_id, + index, + name: slot.name, + size: slot.bytes, + time: slot.completed * 1000 - dayjs().valueOf(), + added: (slot.completed - slot.download_time - slot.postproc_time) * 1000, + state, + progress: 1, + category: slot.category, + }; + }), + ); + return { status, items }; + } + + public async pauseQueueAsync() { + await this.sabNzbApiCallAsync("pause"); + } + + public async pauseItemAsync({ id }: DownloadClientItem) { + await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "pause", value: id })); + } + + public async resumeQueueAsync() { + await this.sabNzbApiCallAsync("resume"); + } + + public async resumeItemAsync({ id }: DownloadClientItem): Promise { + await this.sabNzbApiCallAsync("queue", new URLSearchParams({ name: "resume", value: id })); + } + + //Delete files prevented on completed files. https://github.com/sabnzbd/sabnzbd/issues/2754 + //Works on all other in downloading and post-processing. + //Will stop working as soon as the finished files is moved to completed folder. + public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise { + await this.sabNzbApiCallAsync( + progress !== 1 ? "queue" : "history", + new URLSearchParams({ + name: "delete", + archive: fromDisk ? "0" : "1", + value: id, + del_files: fromDisk ? "1" : "0", + }), + ); + } + + private async sabNzbApiCallAsync(mode: string, searchParams?: URLSearchParams): Promise { + const url = new URL("api", this.integration.url); + url.searchParams.append("output", "json"); + url.searchParams.append("mode", mode); + searchParams?.forEach((value, key) => { + url.searchParams.append(key, value); + }); + url.searchParams.append("apikey", this.getSecretValue("apiKey")); + return fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json(); + }) + .catch((error) => { + if (error instanceof Error) { + throw new Error(error.message); + } else { + throw new Error("Error communicating with SABnzbd"); + } + }); + } + + private getNzbQueueState(status: string): DownloadClientItem["state"] { + switch (status) { + case "Queued": + return "queued"; + case "Paused": + return "paused"; + default: + return "downloading"; + } + } + + private getNzbHistoryState(status: string): DownloadClientItem["state"] { + switch (status) { + case "Completed": + return "completed"; + case "Failed": + return "failed"; + default: + return "processing"; + } + } +} diff --git a/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts b/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts new file mode 100644 index 000000000..eca6fbe03 --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts @@ -0,0 +1,118 @@ +import { z } from "@homarr/validation"; + +export const queueSchema = z.object({ + queue: z.object({ + status: z.string(), + speedlimit: z.string(), + speedlimit_abs: z.string(), + paused: z.boolean(), + noofslots_total: z.number(), + noofslots: z.number(), + limit: z.number(), + start: z.number(), + timeleft: z.string(), + speed: z.string(), + kbpersec: z.string(), + size: z.string(), + sizeleft: z.string(), + mb: z.string(), + mbleft: z.string(), + slots: z.array( + z.object({ + status: z.string(), + index: z.number(), + password: z.string(), + avg_age: z.string(), + script: z.string(), + has_rating: z.boolean().optional(), + mb: z.string(), + mbleft: z.string(), + mbmissing: z.string(), + size: z.string(), + sizeleft: z.string(), + filename: z.string(), + labels: z.array(z.string().or(z.null())).or(z.null()).optional(), + priority: z.string(), + cat: z.string(), + timeleft: z.string(), + percentage: z.string(), + nzo_id: z.string(), + unpackopts: z.string(), + }), + ), + categories: z.array(z.string()).or(z.null()).optional(), + scripts: z.array(z.string()).or(z.null()).optional(), + diskspace1: z.string(), + diskspace2: z.string(), + diskspacetotal1: z.string(), + diskspacetotal2: z.string(), + diskspace1_norm: z.string(), + diskspace2_norm: z.string(), + have_warnings: z.string(), + pause_int: z.string(), + loadavg: z.string().optional(), + left_quota: z.string(), + version: z.string(), + finish: z.number(), + cache_art: z.string(), + cache_size: z.string(), + finishaction: z.null().optional(), + paused_all: z.boolean(), + quota: z.string(), + have_quota: z.boolean(), + queue_details: z.string().optional(), + }), +}); + +export const historySchema = z.object({ + history: z.object({ + noofslots: z.number(), + day_size: z.string(), + week_size: z.string(), + month_size: z.string(), + total_size: z.string(), + last_history_update: z.number(), + slots: z.array( + z.object({ + action_line: z.string(), + series: z.string().or(z.null()).optional(), + script_log: z.string().optional(), + meta: z.null().optional(), + fail_message: z.string(), + loaded: z.boolean(), + id: z.number().optional(), + size: z.string(), + category: z.string(), + pp: z.string(), + retry: z.number(), + script: z.string(), + nzb_name: z.string(), + download_time: z.number(), + storage: z.string(), + has_rating: z.boolean().optional(), + status: z.string(), + script_line: z.string(), + completed: z.number(), + nzo_id: z.string(), + downloaded: z.number(), + report: z.string(), + password: z.string().or(z.null()).optional(), + path: z.string(), + postproc_time: z.number(), + name: z.string(), + url: z.string().or(z.null()).optional(), + md5sum: z.string(), + bytes: z.number(), + url_info: z.string(), + stage_log: z + .array( + z.object({ + name: z.string(), + actions: z.array(z.string()).or(z.null()).optional(), + }), + ) + .optional(), + }), + ), + }), +}); diff --git a/packages/integrations/src/download-client/transmission/transmission-integration.ts b/packages/integrations/src/download-client/transmission/transmission-integration.ts new file mode 100644 index 000000000..f99ffdfab --- /dev/null +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -0,0 +1,98 @@ +import { Transmission } from "@ctrl/transmission"; +import dayjs from "dayjs"; + +import type { DownloadClientData } 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 { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; + +export class TransmissionIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + await this.getClient().getSession(); + } + + public async getClientDataAsync(): Promise { + const type = "torrent"; + const client = this.getClient(); + const { torrents } = (await client.listTorrents()).arguments; + const rates = torrents.reduce( + ({ down, up }, { rateDownload, rateUpload }) => ({ down: down + rateDownload, up: up + rateUpload }), + { down: 0, up: 0 }, + ); + const paused = torrents.find(({ status }) => this.getTorrentState(status) !== "paused") === undefined; + const status: DownloadClientStatus = { paused, rates, type }; + const items = torrents.map((torrent): DownloadClientItem => { + const state = this.getTorrentState(torrent.status); + return { + type, + id: torrent.hashString, + index: torrent.queuePosition, + name: torrent.name, + size: torrent.totalSize, + sent: torrent.uploadedEver, + downSpeed: torrent.percentDone !== 1 ? torrent.rateDownload : undefined, + upSpeed: torrent.rateUpload, + time: + torrent.percentDone === 1 + ? Math.min(torrent.doneDate * 1000 - dayjs().valueOf(), -1) + : Math.max(torrent.eta * 1000, 0), + added: torrent.addedDate * 1000, + state, + progress: torrent.percentDone, + category: torrent.labels, + }; + }); + return { status, items }; + } + + public async pauseQueueAsync() { + const client = this.getClient(); + const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString); + await this.getClient().pauseTorrent(ids); + } + + public async pauseItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().pauseTorrent(id); + } + + public async resumeQueueAsync() { + const client = this.getClient(); + const ids = (await client.listTorrents()).arguments.torrents.map(({ hashString }) => hashString); + await this.getClient().resumeTorrent(ids); + } + + public async resumeItemAsync({ id }: DownloadClientItem): Promise { + await this.getClient().resumeTorrent(id); + } + + public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise { + await this.getClient().removeTorrent(id, fromDisk); + } + + private getClient() { + const baseUrl = new URL(this.integration.url).href; + return new Transmission({ + baseUrl, + username: this.getSecretValue("username"), + password: this.getSecretValue("password"), + }); + } + + private getTorrentState(status: number): DownloadClientItem["state"] { + switch (status) { + case 0: + return "paused"; + case 1: + case 3: + return "stalled"; + case 2: + case 4: + return "leeching"; + case 5: + case 6: + return "seeding"; + default: + return "unknown"; + } + } +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 34329d9cb..7bd3a7683 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -2,14 +2,21 @@ export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; +export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; -export { SabnzbdIntegration } from "./sabnzbd/sabnzbd-integration"; -export { NzbGetIntegration } from "./nzbget/nzbget-integration"; +export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; +export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; +export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; +export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; +export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; // Types +export type { SanitizedIntegration } from "./base/integration"; +export type { IntegrationInput } from "./base/integration"; export type { StreamSession } from "./interfaces/media-server/session"; -export type { UsenetQueueItem } from "./interfaces/usnet-downloads/usenet-queue-item"; -export type { UsenetHistoryItem } from "./interfaces/usnet-downloads/usenet-history-item"; +export type { DownloadClientData } from "./interfaces/downloads/download-client-data"; +export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; +export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; // Helpers export { IntegrationTestConnectionError } from "./base/test-connection-error"; diff --git a/packages/integrations/src/interfaces/downloads/download-client-data.ts b/packages/integrations/src/interfaces/downloads/download-client-data.ts new file mode 100644 index 000000000..4d829240a --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-data.ts @@ -0,0 +1,7 @@ +import type { DownloadClientItem } from "./download-client-items"; +import type { DownloadClientStatus } from "./download-client-status"; + +export interface DownloadClientData { + status: DownloadClientStatus; + items: DownloadClientItem[]; +} diff --git a/packages/integrations/src/interfaces/downloads/download-client-integration.ts b/packages/integrations/src/interfaces/downloads/download-client-integration.ts new file mode 100644 index 000000000..694ed82c4 --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-integration.ts @@ -0,0 +1,18 @@ +import { Integration } from "../../base/integration"; +import type { DownloadClientData } from "./download-client-data"; +import type { DownloadClientItem } from "./download-client-items"; + +export abstract class DownloadClientIntegration extends Integration { + /** Get download client's status and list of all of it's items */ + public abstract getClientDataAsync(): Promise; + /** Pauses the client or all of it's items */ + public abstract pauseQueueAsync(): Promise; + /** Pause a single item using it's ID */ + public abstract pauseItemAsync(item: DownloadClientItem): Promise; + /** Resumes the client or all of it's items */ + public abstract resumeQueueAsync(): Promise; + /** Resume a single item using it's ID */ + public abstract resumeItemAsync(item: DownloadClientItem): Promise; + /** Delete an entry on the client or a file from disk */ + public abstract deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise; +} diff --git a/packages/integrations/src/interfaces/downloads/download-client-items.ts b/packages/integrations/src/interfaces/downloads/download-client-items.ts new file mode 100644 index 000000000..df44a18c8 --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-items.ts @@ -0,0 +1,53 @@ +import type { SanitizedIntegration } from "../../base/integration"; + +/** + * DownloadClientItem + * Description: + * Normalized interface for downloading clients for Usenet and + * Torrents alike, using common properties and few extra optionals + * from each. + */ +export interface DownloadClientItem { + /** Unique Identifier provided by client */ + id: string; + /** Position in queue */ + index: number; + /** Filename */ + name: string; + /** Torrent/Usenet identifier */ + type: "torrent" | "usenet"; + /** Item size in Bytes */ + size: number; + /** Total uploaded in Bytes, only required for Torrent items */ + sent?: number; + /** Download speed in Bytes/s, only required if not complete + * (Says 0 only if it should be downloading but isn't) */ + downSpeed?: number; + /** Upload speed in Bytes/s, only required for Torrent items */ + upSpeed?: number; + /** Positive = eta (until completion, 0 meaning infinite), Negative = time since completion, in milliseconds*/ + time: number; + /** Unix timestamp in milliseconds when the item was added to the client */ + added?: number; + /** Status message, mostly as information to display and not for logic */ + state: UsenetQueueState | UsenetHistoryState | TorrentState; + /** Progress expressed between 0 and 1, can infer completion from progress === 1 */ + progress: number; + /** Category given to the item */ + category?: string | string[]; +} + +export interface ExtendedDownloadClientItem extends DownloadClientItem { + integration: SanitizedIntegration; + received: number; + ratio?: number; + actions: { + resume: () => void; + pause: () => void; + delete: ({ fromDisk }: { fromDisk: boolean }) => void; + }; +} + +type UsenetQueueState = "downloading" | "queued" | "paused"; +type UsenetHistoryState = "completed" | "failed" | "processing"; +type TorrentState = "leeching" | "stalled" | "unknown" | "paused" | "seeding"; diff --git a/packages/integrations/src/interfaces/downloads/download-client-status.ts b/packages/integrations/src/interfaces/downloads/download-client-status.ts new file mode 100644 index 000000000..0dd6604cd --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-status.ts @@ -0,0 +1,21 @@ +import type { SanitizedIntegration } from "../../base/integration"; + +export interface DownloadClientStatus { + /** If client is considered paused */ + paused: boolean; + /** Download/Upload speeds for the client */ + rates: { + down: number; + up?: number; + }; + type: "usenet"|"torrent"; +} + +export interface ExtendedClientStatus extends DownloadClientStatus { + integration: SanitizedIntegration; + /** To derive from current items */ + totalDown?: number; + /** To derive from current items */ + totalUp?: number; + ratio?: number; +} diff --git a/packages/integrations/src/interfaces/usnet-downloads/usenet-history-item.ts b/packages/integrations/src/interfaces/usnet-downloads/usenet-history-item.ts deleted file mode 100644 index ec84e2215..000000000 --- a/packages/integrations/src/interfaces/usnet-downloads/usenet-history-item.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface UsenetHistoryItem { - name: string; - size: number; - id: string; - time: number; -} \ No newline at end of file diff --git a/packages/integrations/src/interfaces/usnet-downloads/usenet-integration.ts b/packages/integrations/src/interfaces/usnet-downloads/usenet-integration.ts deleted file mode 100644 index fa39d9423..000000000 --- a/packages/integrations/src/interfaces/usnet-downloads/usenet-integration.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {Integration} from "../../base/integration"; -import type {UsenetQueueItem} from "./usenet-queue-item"; -import type {UsenetHistoryItem} from "./usenet-history-item"; - -export abstract class UsenetIntegration extends Integration { - public abstract getCurrentQueueAsync(): Promise; - public abstract getHistoryAsync(): Promise; - public abstract pauseQueueAsync(): Promise; - public abstract resumeQueueAsync(): Promise; -} \ No newline at end of file diff --git a/packages/integrations/src/interfaces/usnet-downloads/usenet-queue-item.ts b/packages/integrations/src/interfaces/usnet-downloads/usenet-queue-item.ts deleted file mode 100644 index d4e71657d..000000000 --- a/packages/integrations/src/interfaces/usnet-downloads/usenet-queue-item.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface UsenetQueueItem { - name: string; - progress: number; - sizeInBytes: number; - id: string; - state: 'paused' | 'downloading' | 'queued'; - estimatedTimeOfArrival: number; -} \ No newline at end of file diff --git a/packages/integrations/src/nzbget/nzbget-integration.ts b/packages/integrations/src/nzbget/nzbget-integration.ts deleted file mode 100644 index c7e5bc4b4..000000000 --- a/packages/integrations/src/nzbget/nzbget-integration.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Client as NzbGetClient } from "@jc21/nzbget-jsonrpc-api"; -import type {UsenetQueueItem} from "../interfaces/usnet-downloads/usenet-queue-item"; -import type {UsenetHistoryItem} from "../interfaces/usnet-downloads/usenet-history-item"; -import {UsenetIntegration} from "../interfaces/usnet-downloads/usenet-integration"; - -export class NzbGetIntegration extends UsenetIntegration { - public async testConnectionAsync(): Promise { - const client = this.getClient(); - await client.status(); - } - - public async getCurrentQueueAsync(): Promise { - const sabnzbdClient = this.getClient(); - const files = await sabnzbdClient.listgroups(); - const nzbGetStatus = await sabnzbdClient.status(); - return files.map((file): UsenetQueueItem => { - const status = this.getNzbGetState(file.Status); - return { - id: `${file.NZBID}`, - estimatedTimeOfArrival: (file.RemainingSizeLo * 1000000) / nzbGetStatus.DownloadRate, - name: file.NZBName, - progress: (file.DownloadedSizeMB / file.FileSizeMB) * 100, - sizeInBytes: file.FileSizeLo, - state: status, - }; - }) - } - - public async getHistoryAsync(): Promise { - const history = await this.getClient().history(); - return history.map((history): UsenetHistoryItem => { - return { - id: `${history.NZBID}`, - name: history.Name, - size: history.FileSizeLo, - time: history.DownloadTimeSec * 1000 - }; - }); - } - - public async pauseQueueAsync() { - await this.getClient().pausedownload(); - } - - public async resumeQueueAsync() { - await this.getClient().resumedownload(); - } - - private getClient() { - const url = new URL(this.integration.url); - url.username = this.getSecretValue('username'); - url.password = this.getSecretValue('password'); - url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; - return new NzbGetClient(url); - } - - private getNzbGetState(status: string) { - switch (status) { - case 'QUEUED': - return 'queued'; - case 'PAUSED ': - return 'paused'; - default: - return 'downloading'; - } - } -} \ No newline at end of file diff --git a/packages/integrations/src/sabnzbd/sabnzbd-integration.ts b/packages/integrations/src/sabnzbd/sabnzbd-integration.ts deleted file mode 100644 index 61790809a..000000000 --- a/packages/integrations/src/sabnzbd/sabnzbd-integration.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Client } from "sabnzbd-api"; -import type {UsenetQueueItem} from "../interfaces/usnet-downloads/usenet-queue-item"; -import type {UsenetHistoryItem} from "../interfaces/usnet-downloads/usenet-history-item"; -import dayjs from "dayjs"; -import duration from "dayjs/plugin/duration"; -import {UsenetIntegration} from "../interfaces/usnet-downloads/usenet-integration"; -dayjs.extend(duration); - -export class SabnzbdIntegration extends UsenetIntegration { - public async testConnectionAsync(): Promise { - await this.getSabnzbdClient().fullStatus(); - } - - public async getCurrentQueueAsync(): Promise { - const sabnzbdClient = this.getSabnzbdClient(); - const queue = await sabnzbdClient.queue(); - return queue.slots.map((slot): UsenetQueueItem => { - const status = slot.status as UsenetQueueItem["state"]; - const [hours, minutes, seconds] = slot.timeleft.split(':'); - - let eta = 0; - if (hours !== undefined && minutes !== undefined && seconds !== undefined) { - eta = dayjs.duration({ - hours: parseInt(hours, 10), - minutes: parseInt(minutes, 10), - seconds: parseInt(seconds, 10), - }).asMilliseconds(); - } - return { - id: slot.nzo_id, - estimatedTimeOfArrival: eta, - name: slot.filename, - progress: parseFloat(slot.percentage), - sizeInBytes: parseFloat(slot.mb) * 1000 * 1000, - state: status, - }; - }) - } - - public async getHistoryAsync(): Promise { - const history = await this.getSabnzbdClient().history(); - return history.slots.map((slot): UsenetHistoryItem => { - return { - id: slot.nzo_id, - name: slot.name, - size: slot.bytes, - time: slot.download_time - }; - }); - } - - public async pauseQueueAsync() { - await this.getSabnzbdClient().queuePause(); - } - - public async resumeQueueAsync() { - await this.getSabnzbdClient().queueResume(); - } - - private getSabnzbdClient() { - return new Client(this.integration.url, this.getSecretValue('apiKey')); - } -} \ No newline at end of file diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index a613e85af..3b672e558 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1022,6 +1022,92 @@ export default { description: "Show the current streams on your media servers", option: {}, }, + downloads: { + name: "Download Client", + description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients.", + option: { + columns: { + label: "Columns to show", + }, + showCompletedUsenet: { + label: "Show usenet entries marked as completed", + }, + showCompletedTorrent: { + label: "Show torrent entries marked as completed", + }, + activeTorrentThreshold: { + label: "Hide completed torrent under this threshold (in kiB/s)", + }, + categoryFilter: { + label: "Categories/labels to filter", + }, + filterIsWhitelist: { + label: "Filter as a whitelist", + }, + applyFilterToRatio: { + label: "Use filter to calculate Ratio", + }, + enableRowSorting: { + label: "Enable items sorting", + }, + }, + errors: { + noColumns: "Select Columns in Items", + }, + items: { + actions: { columnTitle: "Controls" }, + added: { columnTitle: "Added", detailsTitle: "Date Added" }, + category: { columnTitle: "Extras", detailsTitle: "Categories (Or extra information)" }, + downSpeed: { columnTitle: "Down", detailsTitle: "Download Speed" }, + index: { columnTitle: "#", detailsTitle: "Current index within client" }, + id: { columnTitle: "Id" }, + integration: { columnTitle: "Integration" }, + name: { columnTitle: "Job name" }, + progress: { columnTitle: "Progress", detailsTitle: "Download Progress" }, + ratio: { columnTitle: "Ratio", detailsTitle: "Torrent ratio (received/sent)" }, + received: { columnTitle: "Total down", detailsTitle: "Total downloaded" }, + sent: { columnTitle: "Total up", detailsTitle: "Total Uploaded" }, + size: { columnTitle: "File Size", detailsTitle: "Total Size of selection/files" }, + state: { columnTitle: "State", detailsTitle: "Job State" }, + time: { columnTitle: "Finish time", detailsTitle: "Time since/to completion" }, + type: { columnTitle: "Type", detailsTitle: "Download Client type" }, + upSpeed: { columnTitle: "Up", detailsTitle: "Upload Speed" }, + }, + states: { + downloading: "Downloading", + queued: "Queued", + paused: "Paused", + completed: "Completed", + failed: "Failed", + processing: "Processing", + leeching: "Leeching", + stalled: "Stalled", + unknown: "Unknown", + seeding: "Seeding", + }, + actions: { + clients: { + modalTitle: "Download clients list", + pause: "Pause all clients/items", + resume: "Resume all clients/items", + }, + client: { + pause: "Pause client", + resume: "Resume client", + }, + item: { + pause: "Pause Item", + resume: "Resume Item", + delete: { + title: "Delete Item", + modalTitle: "Are you sure you want to delete this job?", + entry: "Delete entry", + entryAndFiles: "Delete entry and file(s)", + }, + }, + }, + globalRatio: "Global Ratio", + }, }, widgetPreview: { toggle: { @@ -1481,9 +1567,9 @@ export default { mediaOrganizer: { label: "Media Organizers", }, - "usenet-downloads": { - label: "Usenet downloads" - } + downloads: { + label: "Downloads", + }, }, }, }, diff --git a/packages/widgets/src/_inputs/widget-multiselect-input.tsx b/packages/widgets/src/_inputs/widget-multiselect-input.tsx index 6793dade3..41e38cb59 100644 --- a/packages/widgets/src/_inputs/widget-multiselect-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiselect-input.tsx @@ -3,18 +3,20 @@ import { MultiSelect } from "@mantine/core"; import { translateIfNecessary } from "@homarr/translation"; +import { useI18n } from "@homarr/translation/client"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; import { useFormContext } from "./form"; export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"multiSelect">) => { - const t = useWidgetInputTranslation(kind, property); + const t = useI18n(); + const tWidget = useWidgetInputTranslation(kind, property); const form = useFormContext(); return ( typeof option === "string" ? option @@ -23,7 +25,7 @@ export const WidgetMultiSelectInput = ({ property, kind, options }: CommonWidget label: translateIfNecessary(t, option.label) ?? option.value, }, )} - description={options.withDescription ? t("description") : undefined} + description={options.withDescription ? tWidget("description") : undefined} searchable={options.searchable} {...form.getInputProps(`options.${property}`)} /> diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 0ee0e36b9..83ccf32a9 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -93,6 +93,7 @@ export type WidgetComponentProps = WidgetProps } & { boardId: string | undefined; // undefined when in preview mode isEditMode: boolean; + setOptions: ({ newOptions }: { newOptions: Record }) => void; width: number; height: number; }; diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx new file mode 100644 index 000000000..cabdc5c60 --- /dev/null +++ b/packages/widgets/src/downloads/component.tsx @@ -0,0 +1,808 @@ +"use client"; + +import "../widgets-common.css"; + +import { useMemo, useState } from "react"; +import type { MantineStyleProp } from "@mantine/core"; +import { + ActionIcon, + Avatar, + AvatarGroup, + Button, + Center, + Divider, + Group, + Modal, + Paper, + Progress, + Space, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { useDisclosure, useListState } from "@mantine/hooks"; +import { + IconAlertTriangle, + IconCirclesRelation, + IconInfinity, + IconInfoCircle, + IconPlayerPause, + IconPlayerPlay, + IconTrash, +} from "@tabler/icons-react"; +import dayjs from "dayjs"; +import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table"; +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; + +import { clientApi } from "@homarr/api/client"; +import { humanFileSize } from "@homarr/common"; +import { getIconUrl } from "@homarr/definitions"; +import type { + DownloadClientData, + ExtendedClientStatus, + ExtendedDownloadClientItem, + SanitizedIntegration, +} from "@homarr/integrations"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; + +//TODO: +// - Data Subscription permission issues <- Need help +// - table tbody hide under thead and keep transparency <- Need help +// - Add integrations to shouldHide options <- Potential help needed +// - default sorting option <- but I don't wannaaaaa.... +// - Move columns ratio table to css vars +// - tests maybe? +// - Unexpected value xxxxx parsing width/height attribute <- Need help (Actually impacts all widgets using cq and var sizes...), Not critical + +//Ratio table for relative width between columns +const columnsRatios: Record = { + actions: 2, + added: 4, + category: 1, + downSpeed: 3, + id: 1, + index: 1, + integration: 1, + name: 8, + progress: 4, + ratio: 2, + received: 3, + sent: 3, + size: 3, + state: 3, + time: 4, + type: 2, + upSpeed: 3, +}; + +export default function DownloadClientsWidget({ + isEditMode, + integrationIds, + options, + serverData, + setOptions, +}: WidgetComponentProps<"downloads">) { + const [currentItems, currentItemsHandlers] = useListState<{ + integration: SanitizedIntegration; + data: DownloadClientData; + }>(serverData?.initialData.data ?? []); + + //Translations + const t = useScopedI18n("widget.downloads"); + const tCommon = useScopedI18n("common"); + const noIntegrationError = useScopedI18n("integration.permission")("use"); + //Item modal state and selection + const [clickedIndex, setClickedIndex] = useState(0); + const [opened, { open, close }] = useDisclosure(false); + + if (integrationIds.length === 0) + return ( +
+ {noIntegrationError} +
+ ); + + if (options.columns.length === 0) + return ( +
+ {t("errors.noColumns")} +
+ ); + + //Get API mutation functions + const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(); + const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(); + const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(); + + //Subrscribe to dynamic data changes + clientApi.widget.downloads.subscribeToData.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + currentItemsHandlers.applyWhere( + (pair) => pair.integration.id === data.integration.id, + (pair) => { + return { + ...pair, + data: data.data, + }; + }, + ); + }, + }, + ); + + //Flatten Data array for which each element has it's integration, data (base + calculated) and actions. Memoized on data subscription + const data = useMemo( + () => + currentItems + .filter(({ integration }) => integrationIds.includes(integration.id)) + .flatMap((pair) => + pair.data.items + .filter( + ({ category }) => + options.filterIsWhitelist === + options.categoryFilter.some((filter) => + (Array.isArray(category) ? category : [category]).includes(filter), + ), + ) + .filter( + ({ type, progress, upSpeed }) => + (type === "torrent" && + ((progress === 1 && + options.showCompletedTorrent && + upSpeed! >= Number(options.activeTorrentThreshold) * 1024) || + progress !== 1)) || + (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)), + ) + .map((item): ExtendedDownloadClientItem => { + const received = Math.floor(item.size * item.progress); + return { + integration: pair.integration, + ...item, + category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, + received, + ratio: item.sent !== undefined ? item.sent / received : undefined, + actions: { + resume: () => mutateResumeItem({ integrationIds: [pair.integration.id], item }), + pause: () => mutatePauseItem({ integrationIds: [pair.integration.id], item }), + delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds: [pair.integration.id], item, fromDisk }), + }, + }; + }), + ), + [currentItems, integrationIds, options], + ); + + //Flatten Clients Array for which each elements has the integration and general client infos. + const clients = useMemo( + () => + currentItems + .filter(({ integration }) => integrationIds.includes(integration.id)) + .flatMap((pair): ExtendedClientStatus => { + const isTorrent = ["qBittorrent", "deluge", "transmission"].includes(pair.integration.kind); + /** Derived from current items */ + const { totalUp, totalDown } = pair.data.items + .filter( + ({ category }) => + !options.applyFilterToRatio || + pair.data.status.type !== "torrent" || + options.filterIsWhitelist === + options.categoryFilter.some((filter) => + (Array.isArray(category) ? category : [category]).includes(filter), + ), + ) + .reduce( + ({ totalUp, totalDown }, { sent, size, progress }) => ({ + totalUp: isTorrent ? totalUp! + sent! : undefined, + totalDown: totalDown + size * progress, + }), + { totalDown: 0, totalUp: isTorrent ? 0 : undefined }, + ); + return { + integration: pair.integration, + totalUp, + totalDown, + ratio: totalUp === undefined ? undefined : totalUp / totalDown, + ...pair.data.status, + }; + }).sort(({type: typeA},{type: typeB}) => typeA.length - typeB.length), + [currentItems, integrationIds, options], + ); + + //Check existing types between torrents and usenet + const integrationTypes: string[] = []; + if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent"); + if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet"); + + //Set the visibility of columns depending on widget settings and available data. + const columnVisibility: MRT_VisibilityState = { + id: options.columns.includes("id"), + actions: options.columns.includes("actions"), + added: options.columns.includes("added"), + category: options.columns.includes("category"), + downSpeed: options.columns.includes("downSpeed"), + index: options.columns.includes("index"), + integration: options.columns.includes("integration") && clients.length > 1, + name: options.columns.includes("name"), + progress: options.columns.includes("progress"), + ratio: options.columns.includes("ratio") && integrationTypes.includes("torrent"), + received: options.columns.includes("received"), + sent: options.columns.includes("sent") && integrationTypes.includes("torrent"), + size: options.columns.includes("size"), + state: options.columns.includes("state"), + time: options.columns.includes("time"), + type: options.columns.includes("type") && integrationTypes.length > 1, + upSpeed: options.columns.includes("upSpeed") && integrationTypes.includes("torrent"), + } satisfies Record; + + //Set a relative width using ratio table + const totalWidth = options.columns.reduce( + (count: number, column) => (columnVisibility[column] ? count + columnsRatios[column] : count), + 0, //<-- out of table spacing value + ); + + //Default styling behavior for stopping interaction when editing. (Applied everywhere except the table header) + const editStyle: MantineStyleProp = { + pointerEvents: isEditMode ? "none" : undefined, + }; + + //General style sizing as vars + const baseStyle: MantineStyleProp = { + "--totalWidth": totalWidth, + "--ratioWidth": "calc(100cqw / var(--totalWidth))", + "--text-fz": "calc(var(--ratioWidth) * 0.45)", + "--button-fz": "calc(var(--ratioWidth)* 0.6)", + "--mrt-base-background-color": "transparent", + }; + + //Base element in common with all columns + const columnsDefBase = ({ + key, + showHeader, + align, + }: { + key: keyof ExtendedDownloadClientItem; + showHeader: boolean; + align?: "center" | "left" | "right" | "justify" | "char"; + }): MRT_ColumnDef => { + const style: MantineStyleProp = { + minWidth: 0, + width: "var(--width)", + height: "var(--ratioWidth)", + padding: "calc(var(--ratioWidth) * 0.2)", + transition: "unset", + "--keyWidth": columnsRatios[key], + "--width": "calc((var(--keyWidth)/var(--totalWidth) * 100cqw))", + align: "center", + }; + return { + id: key, + accessorKey: key, + header: key, + size: columnsRatios[key], + mantineTableBodyCellProps: { style, align }, + mantineTableHeadCellProps: { + style: { ...style, "--mrt-base-background-color": "var(--background-color)" }, + align, + }, + Header: () => (showHeader && !isEditMode ? {t(`items.${key}.columnTitle`)} : ""), + }; + }; + + //Make columns and cell elements, Memoized to data with deps on data and EditMode + const columns = useMemo[]>( + () => [ + { + ...columnsDefBase({ key: "actions", showHeader: false, align: "center" }), + enableSorting: false, + Cell: ({ cell, row }) => { + const actions = cell.getValue(); + const isPaused = row.original.state === "paused"; + const [opened, { open, close }] = useDisclosure(false); + return ( + + + + {isPaused ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + ); + }, + }, + { + ...columnsDefBase({ key: "added", showHeader: true, align: "center" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const added = cell.getValue(); + return {added !== undefined ? dayjs(added).fromNow() : "unknown"}; + }, + }, + { + ...columnsDefBase({ key: "category", showHeader: false, align: "center" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const category = cell.getValue(); + return ( + category !== undefined && ( + + + + ) + ); + }, + }, + { + ...columnsDefBase({ key: "downSpeed", showHeader: true, align: "right" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const downSpeed = cell.getValue(); + return {downSpeed !== undefined && humanFileSize(downSpeed) + "/s"}; + }, + }, + { + ...columnsDefBase({ key: "id", showHeader: false }), + enableSorting: false, + Cell: ({ cell }) => { + const id = cell.getValue(); + return ( + + + + ); + }, + }, + { + ...columnsDefBase({ key: "index", showHeader: true, align: "center" }), + Cell: ({ cell }) => { + const index = cell.getValue(); + return {index}; + }, + }, + { + ...columnsDefBase({ key: "integration", showHeader: false, align: "center" }), + Cell: ({ cell }) => { + const integration = cell.getValue(); + return ( + + + + ); + }, + }, + { + ...columnsDefBase({ key: "name", showHeader: true }), + Cell: ({ cell }) => { + const name = cell.getValue(); + return ( + + {name} + + ); + }, + }, + { + ...columnsDefBase({ key: "progress", showHeader: true, align: "center" }), + Cell: ({ cell, row }) => { + const progress = cell.getValue(); + return ( + + + {new Intl.NumberFormat("en", { style: "percent", notation: "compact", unitDisplay: "narrow" }).format( + progress, + )} + + + + ); + }, + }, + { + ...columnsDefBase({ key: "ratio", showHeader: true, align: "center" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const ratio = cell.getValue(); + return ratio !== undefined && {ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}; + }, + }, + { + ...columnsDefBase({ key: "received", showHeader: true, align: "right" }), + Cell: ({ cell }) => { + const received = cell.getValue(); + return {humanFileSize(received)}; + }, + }, + { + ...columnsDefBase({ key: "sent", showHeader: true, align: "right" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const sent = cell.getValue(); + return sent !== undefined && {humanFileSize(sent)}; + }, + }, + { + ...columnsDefBase({ key: "size", showHeader: true, align: "right" }), + Cell: ({ cell }) => { + const size = cell.getValue(); + return {humanFileSize(size)}; + }, + }, + { + ...columnsDefBase({ key: "state", showHeader: true }), + enableSorting: false, + Cell: ({ cell }) => { + const state = cell.getValue(); + return {t(`states.${state}`)}; + }, + }, + { + ...columnsDefBase({ key: "time", showHeader: true, align: "center" }), + Cell: ({ cell }) => { + const time = cell.getValue(); + return time === 0 ? ( + + ) : ( + {dayjs().add(time).fromNow()} + ); + }, + }, + { + ...columnsDefBase({ key: "type", showHeader: true }), + Cell: ({ cell }) => { + const type = cell.getValue(); + return {type}; + }, + }, + { + ...columnsDefBase({ key: "upSpeed", showHeader: true, align: "right" }), + sortUndefined: "last", + Cell: ({ cell }) => { + const upSpeed = cell.getValue(); + return upSpeed !== undefined && {humanFileSize(upSpeed) + "/s"}; + }, + }, + ], + [clickedIndex, isEditMode, data, integrationIds, options], + ); + + //Table build and config + const table = useMantineReactTable({ + columns, + data, + enablePagination: false, + enableTopToolbar: false, + enableBottomToolbar: false, + enableColumnActions: false, + enableSorting: options.enableRowSorting && !isEditMode, + enableMultiSort: true, + enableStickyHeader: false, + enableColumnOrdering: isEditMode, + enableRowVirtualization: true, + rowVirtualizerOptions: { overscan: 5 }, + mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined }, + mantineTableContainerProps: { style: { height: "100%" } }, + mantineTableProps: { + className: "downloads-widget-table", + style: { + "--sortButtonSize": "calc(var(--ratioWidth)*0.6)", + "--dragButtonSize": "calc(var(--ratioWidth)*0.6)", + }, + }, + mantineTableBodyProps: { style: editStyle }, + mantineTableBodyCellProps: ({ cell, row }) => ({ + onClick: () => { + setClickedIndex(row.index); + cell.column.id !== "actions" && open(); + }, + }), + onColumnOrderChange: (order) => { + const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column)); + setOptions({ newOptions: { columns: columnOrder } }); + }, + initialState: { + columnVisibility: { + actions: false, + added: false, + category: false, + downSpeed: false, + id: false, + index: false, + integration: false, + name: false, + progress: false, + ratio: false, + received: false, + sent: false, + size: false, + state: false, + time: false, + type: false, + upSpeed: false, + } satisfies Record, + columnOrder: options.columns, + }, + state: { + columnVisibility, + columnOrder: options.columns, + }, + }); + + const isLangRtl = tCommon("rtl", { value: "0", symbol: "1" }).startsWith("1"); + const globalTrafic = clients + .filter(({ integration: { kind } }) => ["qBittorrent", "deluge", "transmission"].includes(kind)) + .reduce( + ({ up, down }, { totalUp, totalDown }) => ({ + up: up + totalUp!, + down: down + totalDown!, + }), + { up: 0, down: 0 }, + ); + + //The actual widget + return ( + + + + {integrationTypes.includes("torrent") && ( + + {tCommon("rtl", { value: t("globalRatio"), symbol: tCommon("symbols.colon") })} + {(globalTrafic.up / globalTrafic.down).toFixed(2)} + + )} + + + + + ); +} + +interface ItemInfoModalProps { + items: ExtendedDownloadClientItem[]; + currentIndex: number; + opened: boolean; + onClose: () => void; +} + +const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => { + const item = useMemo(() => items[currentIndex], [currentIndex, opened]); + const t = useScopedI18n("widget.downloads.states"); + if (item === undefined) return; + return ( + + + {item.name} + + + {`${item.integration.name} (${item.integration.kind})`} + + + + + + + + + + + + + + + + + ); +}; + +const NormalizedLine = ({ + itemKey, + values, +}: { + itemKey: Exclude; + values?: number | string | string[]; +}) => { + if (typeof values !== "number" && (values === undefined || values.length === 0)) return; + const t = useScopedI18n("widget.downloads.items"); + const tCommon = useScopedI18n("common"); + const translatedKey = t(`${itemKey}.detailsTitle`); + const isLangRtl = tCommon("rtl", { value: "0", symbol: "1" }).startsWith("1"); //Maybe make a common "isLangRtl" somewhere + const keyString = tCommon("rtl", { value: translatedKey, symbol: tCommon("symbols.colon") }); + return ( + + {keyString} + {Array.isArray(values) ? ( + + {values.map((value) => ( + {value} + ))} + + ) : ( + {values} + )} + + ); +}; + +interface ClientsControlProps { + clients: ExtendedClientStatus[]; + style?: MantineStyleProp; +} + +const ClientsControl = ({ clients, style }: ClientsControlProps) => { + const pausedIntegrations: string[] = []; + const activeIntegrations: string[] = []; + clients.map((client) => + client.paused ? pausedIntegrations.push(client.integration.id) : activeIntegrations.push(client.integration.id), + ); + const totalSpeed = humanFileSize(clients.reduce((count, { rates: { down } }) => count + down, 0)) + "/s"; + const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(); + const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(); + const [opened, { open, close }] = useDisclosure(false); + const t = useScopedI18n("widget.downloads.actions"); + return ( + + + {clients.map((client) => ( + + ))} + + + mutateResumeQueue({ integrationIds: pausedIntegrations })} + > + + + + + + {clients.map((client) => ( + + + + + + + + {client.rates.up !== undefined ? ( + + {"↑ " + humanFileSize(client.rates.up) + "/s"} + {"-"} + {humanFileSize(client.totalUp ?? 0)} + + ) : undefined} + + + {"↓ " + humanFileSize(client.rates.down) + "/s"} + {"-"} + {humanFileSize(Math.floor(client.totalDown ?? 0))} + + + + + + + {client.integration.name} + + + + { + (client.paused ? mutateResumeQueue : mutatePauseQueue)({ + integrationIds: [client.integration.id], + }); + }} + > + {client.paused ? : } + + + + + ))} + + + + + mutatePauseQueue({ integrationIds: activeIntegrations })} + > + + + + + ); +}; diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts new file mode 100644 index 000000000..392c79420 --- /dev/null +++ b/packages/widgets/src/downloads/index.ts @@ -0,0 +1,77 @@ +import { IconDownload } from "@tabler/icons-react"; + +import { z } from "@homarr/validation"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", { + icon: IconDownload, + options: optionsBuilder.from( + (factory) => ({ + columns: factory.multiSelect({ + defaultValue: ["integration", "name", "progress", "time", "actions"], + options: ( + [ + "id", + "actions", + "added", + "category", + "downSpeed", + "index", + "integration", + "name", + "progress", + "ratio", + "received", + "sent", + "size", + "state", + "time", + "type", + "upSpeed", + ] as const + ).map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`) })), + searchable: true, + }), + showCompletedUsenet: factory.switch({ + defaultValue: true, + }), + showCompletedTorrent: factory.switch({ + defaultValue: true, + }), + activeTorrentThreshold: factory.number({ + //in KiB/s + validate: z.number().min(0), + defaultValue: 0, + step: 1, + }), + categoryFilter: factory.multiText({ + //defaultValue: [] as string[]; + }), + filterIsWhitelist: factory.switch({ + defaultValue: false, + }), + applyFilterToRatio: factory.switch({ + defaultValue: true, + }), + enableRowSorting: factory.switch({ + defaultValue: false, + }), + }), + { + showCompletedUsenet: { + shouldHide: () => false, //Get from presence of usenet client in integration list + }, + showCompletedTorrent: { + shouldHide: () => false, //Get from presence of torrent client in integration list + }, + applyFilterToRatio: { + shouldHide: () => false, //Get from presence of torrent client in integration list + }, + }, + ), + supportedIntegrations: ["sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"], +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/downloads/serverData.ts b/packages/widgets/src/downloads/serverData.ts new file mode 100644 index 000000000..35e9de2f7 --- /dev/null +++ b/packages/widgets/src/downloads/serverData.ts @@ -0,0 +1,25 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../definition"; + +export default async function getServerDataAsync({ integrationIds }: WidgetProps<"downloads">) { + if (integrationIds.length === 0) { + return { + initialData: { + data: [], + }, + }; + } + + const data = await api.widget.downloads.getData({ + integrationIds, + }); + + return { + initialData: { + data, + }, + }; +} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 8f4aaff91..062b1b9c4 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -10,6 +10,7 @@ import * as calendar from "./calendar"; import * as clock from "./clock"; import type { WidgetComponentProps } from "./definition"; import * as dnsHoleSummary from "./dns-hole/summary"; +import * as downloads from "./downloads"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; import * as mediaServer from "./media-server"; @@ -18,7 +19,6 @@ import * as smartHomeEntityState from "./smart-home/entity-state"; import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; import * as video from "./video"; import * as weather from "./weather"; -import * as usenetDownloads from "./usenet-downloads"; export { reduceWidgetOptionsWithDefaultValues } from "./options"; @@ -38,7 +38,7 @@ export const widgetImports = { "smartHome-executeAutomation": smartHomeExecuteAutomation, mediaServer, calendar, - "usenet-downloads": usenetDownloads + downloads: downloads, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/usenet-downloads/component.tsx b/packages/widgets/src/usenet-downloads/component.tsx deleted file mode 100644 index 61ab8c6f5..000000000 --- a/packages/widgets/src/usenet-downloads/component.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import type {WidgetComponentProps} from "../definition"; -import {clientApi} from "@homarr/api/client"; -import type {UsenetQueueItem} from "@homarr/integrations"; -import {Stack} from "@mantine/core"; -import {useListState} from "@mantine/hooks"; -import {useMemo} from "react"; - -export default function VideoWidget({integrationIds, serverData}: WidgetComponentProps<"usenet-downloads">) { - const [currentQueueItems, currentQueueItemsHandlers] = useListState<{ - integrationId: string; - queue: UsenetQueueItem[] - }>( - serverData?.initialData.queue ?? [], - ); - - const {mutate: mutateResumeQueue} = clientApi.widget.usenetDownloads.resume.useMutation(); - const {mutate: mutatePauseQueue} = clientApi.widget.usenetDownloads.pause.useMutation(); - - clientApi.widget.usenetDownloads.subscribeToQueue.useSubscription({ - integrationIds - }, { - onData: (data) => { - currentQueueItemsHandlers.applyWhere( - (pair) => pair.integrationId === data.integrationId, - (pair) => { - return { - ...pair, - queue: data.data, - }; - }, - ); - } - }); - - // Only render the flat list of queue items when the currentQueueItems change - // Otherwise it will always create a new array reference and cause the table to re-render - const flatQueueItems = useMemo(() => currentQueueItems.flatMap((pair) => pair.queue), [currentQueueItems]); - - return - {JSON.stringify(flatQueueItems)} - - - -} \ No newline at end of file diff --git a/packages/widgets/src/usenet-downloads/index.ts b/packages/widgets/src/usenet-downloads/index.ts deleted file mode 100644 index 8d666dd02..000000000 --- a/packages/widgets/src/usenet-downloads/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IconDownload } from "@tabler/icons-react"; - -import { createWidgetDefinition } from "../definition"; - -export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("usenet-downloads", { - icon: IconDownload, - options: {}, - supportedIntegrations: ['sabNzbd', "nzbGet"], -}) - .withServerData(() => import("./serverData")) - .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/usenet-downloads/serverData.ts b/packages/widgets/src/usenet-downloads/serverData.ts deleted file mode 100644 index d47afa7e6..000000000 --- a/packages/widgets/src/usenet-downloads/serverData.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use server"; -import type {WidgetProps} from "../definition"; -import {api} from "@homarr/api/server"; - -export default async function getServerDataAsync({ integrationIds }: WidgetProps<"usenet-downloads">) { - if (integrationIds.length === 0) { - return { - initialData: { - queue: [] - }, - }; - } - - const queue = await api.widget.usenetDownloads.getQueue({ - integrationIds - }); - - return { - initialData: { - queue, - } - } -} \ No newline at end of file diff --git a/packages/widgets/src/widgets-common.css b/packages/widgets/src/widgets-common.css new file mode 100644 index 000000000..326285aa4 --- /dev/null +++ b/packages/widgets/src/widgets-common.css @@ -0,0 +1,21 @@ +.downloads-widget-table { + .mrt-table-head-cell-labels { + min-height: var(--ratioWidth); + gap: 0; + padding: 0; + } + .mrt-grab-handle-button { + margin: unset; + width: var(--dragButtonSize); + min-width: var(--dragButtonSize); + height: var(--dragButtonSize); + min-height: var(--dragButtonSize); + } + .mrt-table-head-sort-button { + margin: unset; + width: var(--sortButtonSize); + min-width: var(--sortButtonSize); + height: var(--sortButtonSize); + min-height: var(--sortButtonSize); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0cdf8c67..c125296df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,16 +139,16 @@ importers: version: 5.51.11(@tanstack/react-query@5.51.11(react@18.3.1))(next@14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react@18.3.1) '@trpc/client': specifier: next - version: 11.0.0-rc.466(@trpc/server@11.0.0-rc.466) + version: 11.0.0-rc.467(@trpc/server@11.0.0-rc.467) '@trpc/next': specifier: next - version: 11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/react-query@11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.466)(next@14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/react-query@11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.467)(next@14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/react-query': specifier: next - version: 11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/server': specifier: next - version: 11.0.0-rc.466 + version: 11.0.0-rc.467 '@xterm/addon-canvas': specifier: ^0.7.0 version: 0.7.0(@xterm/xterm@5.5.0) @@ -476,13 +476,13 @@ importers: version: link:../validation '@trpc/client': specifier: next - version: 11.0.0-rc.466(@trpc/server@11.0.0-rc.466) + version: 11.0.0-rc.467(@trpc/server@11.0.0-rc.467) '@trpc/react-query': specifier: next - version: 11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@trpc/server': specifier: next - version: 11.0.0-rc.466 + version: 11.0.0-rc.467 dockerode: specifier: ^4.0.2 version: 4.0.2 @@ -884,6 +884,15 @@ importers: packages/integrations: dependencies: + '@ctrl/deluge': + specifier: ^6.1.0 + version: 6.1.0 + '@ctrl/qbittorrent': + specifier: ^8.2.0 + version: 8.2.0 + '@ctrl/transmission': + specifier: ^6.1.0 + version: 6.1.0 '@homarr/common': specifier: workspace:^0.1.0 version: link:../common @@ -899,12 +908,12 @@ importers: '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation + '@jc21/nzbget-jsonrpc-api': + specifier: ^1.0.0 + version: 1.0.0 '@jellyfin/sdk': specifier: ^0.10.0 version: 0.10.0(axios@1.7.2) - sabnzbd-api: - specifier: ^1.5.0 - version: 1.5.0 devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1598,6 +1607,30 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@ctrl/deluge@6.1.0': + resolution: {integrity: sha512-n8237DbSHlANTLBS3rxIKsnC3peltifJhV2h6fWp5lb7BNZuA3LFz0gVS02aAhj351G3A0ScSYLmuAAL2ld/Nw==} + engines: {node: '>=18'} + + '@ctrl/magnet-link@4.0.2': + resolution: {integrity: sha512-wENP7LH4BmCjz+gXVq7Nzz20zMjY/huuG7aDk/yu/LhFdC84e/l8222rCIAo0lwhU451lFcJKLcOmtG6TNrBAQ==} + engines: {node: '>=18'} + + '@ctrl/qbittorrent@8.2.0': + resolution: {integrity: sha512-7TDvm3sknQxLGFQLTWHjvyLNj6stbfhsvbvNx3tZUpVqlGNXwV7rdyXvJYdcL2voGelAPOjusFqTL4wVjchVFg==} + engines: {node: '>=18'} + + '@ctrl/shared-torrent@6.0.0': + resolution: {integrity: sha512-BZAPDv8syFArFTAAeb560JSBNTajFtP3G/5eYiUMsg0upGAQs6NWGiHYbyjvAt8uHCSzxXsiji/Wvq1b7CvXSQ==} + engines: {node: '>=18'} + + '@ctrl/torrent-file@4.0.0': + resolution: {integrity: sha512-FmecYRCzpuPqAQhe/M1hHU81j+HjSzagEJJSQcJfdqc0cHVqNt7emo7YiJP3oOoVjq256vH4+ZimGErwFNvRpQ==} + engines: {node: '>=18'} + + '@ctrl/transmission@6.1.0': + resolution: {integrity: sha512-5LjNdNOFqeWKJ7yym2Iz6+bLpBWetE3gbH5AMhgPfpQdXlyoCvX4Ro/fD5pSDuTu4tnen3Eob2cHBgWmqPU47A==} + engines: {node: '>=18'} + '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} @@ -2093,6 +2126,9 @@ packages: resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jc21/nzbget-jsonrpc-api@1.0.0': + resolution: {integrity: sha512-MQOKMyHUk+xWmuIoDKkbG39jED8zpN3s+69phok9F+M5SZ6sUVJJ90Jb0mPDTACVGHTRKl+OolgaiATPuEMTCg==} + '@jellyfin/sdk@0.10.0': resolution: {integrity: sha512-fUUwiPOGQEFYxnS9olYkv7GXIX5N9JYdRBR8bapN86OhbHWzL1JHgWf/sAUcNTQGlCWMKTJqve4KFOQB1FlMAQ==} peerDependencies: @@ -2272,6 +2308,24 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nofrills/collections@3.8.7': + resolution: {integrity: sha512-vyBU0sgMk2FeE9OENRgHMUodjrhFkXKTITeOs1AXldL2fmvHCGiUHtG8tcEcDAaIylVNwa+jteGG/7df8BykMg==} + + '@nofrills/http@3.5.12': + resolution: {integrity: sha512-sKLmFE75zHkQttlOhf1jOgCQfJqtLQyvYt6aHZVk9BiTBGeLITutWbk0WVG6Sv61vD6DbUdTtgdW5X4/LfEEaQ==} + + '@nofrills/lincoln-debug@3.6.13': + resolution: {integrity: sha512-qWE4mxFrXHleA/oFvE8GSqupM5FDatKsKjYw3Bf8EoYwmS8FWCVs6bRoL8F3o0ZiJz1ckRpVYSgNShzZi/qQAQ==} + + '@nofrills/lincoln@3.5.9': + resolution: {integrity: sha512-wArbB+OxSegBi176PNwT6hTr9Rpg/txse+qPh0IW1WGaTbB/ePMlnf5ajnpELV/+c15TkC8sgaJIRWR+b44vJQ==} + + '@nofrills/scrubs@3.6.7': + resolution: {integrity: sha512-gwF0v/5jopJh6ewac8MKN8jZAnwSINs0gYNrdM9KW51anlDB75doSIj52web4/5lye9dkA7roQIeAqmW0WFVMQ==} + + '@nofrills/types@3.5.7': + resolution: {integrity: sha512-cnWi3xv0i9zmZyr2KExFEsGOUQcwkhgZ7SWbaHsspeBnLPqhjozmRksHdRQ+FvHcw1GyJez3pZhbYfa8wDC7hg==} + '@panva/hkdf@1.1.1': resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} @@ -2371,20 +2425,12 @@ packages: cpu: [x64] os: [win32] - '@sindresorhus/is@4.6.0': - resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} - engines: {node: '>=10'} - '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} '@swc/helpers@0.5.5': resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} - '@szmarczak/http-timer@4.0.6': - resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} - engines: {node: '>=10'} - '@t3-oss/env-core@0.10.1': resolution: {integrity: sha512-GcKZiCfWks5CTxhezn9k5zWX3sMDIYf6Kaxy2Gx9YEQftFcz8hDRN56hcbylyAO3t4jQnQ5ifLawINsNgCDpOg==} peerDependencies: @@ -2657,18 +2703,18 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - '@trpc/client@11.0.0-rc.466': - resolution: {integrity: sha512-8nEYgDPvTrXzn00FgLEOU/BHtH89LVQ0JTiW2efa4wW2XIrj3Nv/QKCRkNXV6yu7VSefwU+p2PulUL6TjNlzAg==} + '@trpc/client@11.0.0-rc.467': + resolution: {integrity: sha512-ovZaGdAUl+EEmtJJc5uuo95B0gw8+q3jwNjUQQmmSMU5Isq4sYdjIWNkhbrFtR8CovllFyrRrjAgCWdaOTEY4g==} peerDependencies: - '@trpc/server': 11.0.0-rc.466+1e8344ea7 + '@trpc/server': 11.0.0-rc.467+8f72171d6 - '@trpc/next@11.0.0-rc.466': - resolution: {integrity: sha512-kB6bpi81ABUzD2xy6I4+ELbPLqTXqweCouw2jnVkYH1yGUJqKYur/M8pWG1qA5fh1dReOt1zouaTyAq4QqzRUw==} + '@trpc/next@11.0.0-rc.467': + resolution: {integrity: sha512-AJUi5eATaJ7RAy9INkSUrF7n/0cJYP+2FNZtgJWxpm5t/WDmb3PxvxCy1qU1Sstc/zoDqaOZZ1y66xTnuCFDSA==} peerDependencies: '@tanstack/react-query': ^5.49.2 - '@trpc/client': 11.0.0-rc.466+1e8344ea7 - '@trpc/react-query': 11.0.0-rc.466+1e8344ea7 - '@trpc/server': 11.0.0-rc.466+1e8344ea7 + '@trpc/client': 11.0.0-rc.467+8f72171d6 + '@trpc/react-query': 11.0.0-rc.467+8f72171d6 + '@trpc/server': 11.0.0-rc.467+8f72171d6 next: '*' react: '>=16.8.0' react-dom: '>=16.8.0' @@ -2678,17 +2724,17 @@ packages: '@trpc/react-query': optional: true - '@trpc/react-query@11.0.0-rc.466': - resolution: {integrity: sha512-nt0hcskUrr1+kj8kRRhucB6h+VuK55By7ZR1oxaIGdbPygwfXoi2n4+s3t7kjZsJzugKDWtDnwJGfGFaUIP62A==} + '@trpc/react-query@11.0.0-rc.467': + resolution: {integrity: sha512-PNpHgISXJ60s0fJc6JUomKe3iu1wj6pZNFHJgQecAEK0gs1y6VM8Oh8CHgZg8+J/KDP/UtUmBcbpFP9l8Nq48w==} peerDependencies: '@tanstack/react-query': ^5.49.2 - '@trpc/client': 11.0.0-rc.466+1e8344ea7 - '@trpc/server': 11.0.0-rc.466+1e8344ea7 + '@trpc/client': 11.0.0-rc.467+8f72171d6 + '@trpc/server': 11.0.0-rc.467+8f72171d6 react: '>=18.2.0' react-dom: '>=18.2.0' - '@trpc/server@11.0.0-rc.466': - resolution: {integrity: sha512-/40QZXyZCc3HvgJbQ2+IqCGm4m+fJs7q7gMp6nLiuPB3FIJJeV0r+w1M/NGcG5avbCJ3A5Qy45s4N1glcMv1ug==} + '@trpc/server@11.0.0-rc.467': + resolution: {integrity: sha512-94Gv26ALuBfxgFlSGV3x2uF2ixUEViuK0m3IPKOvCTMreisZkBqyTa3NkBcuPZW/AMUieM5P4Q2NrbHTIA0fKQ==} '@tsconfig/node10@1.0.9': resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} @@ -2734,9 +2780,6 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} - '@types/cacheable-request@6.0.3': - resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - '@types/chroma-js@2.4.4': resolution: {integrity: sha512-/DTccpHTaKomqussrn+ciEvfW4k6NAHzNzs/sts1TCqg333qNxOhy8TNIoQCmbGG3Tl8KdEhkGAssb1n3mTXiQ==} @@ -2776,9 +2819,6 @@ packages: '@types/glob@7.2.0': resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} - '@types/http-cache-semantics@4.0.4': - resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} @@ -2794,9 +2834,6 @@ packages: '@types/keygrip@1.0.6': resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - '@types/keyv@3.1.4': - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -2839,9 +2876,6 @@ packages: '@types/react@18.3.3': resolution: {integrity: sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==} - '@types/responselike@1.0.3': - resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -3361,14 +3395,6 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacheable-lookup@5.0.4: - resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} - engines: {node: '>=10.6.0'} - - cacheable-request@7.0.4: - resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} - engines: {node: '>=8'} - call-bind@1.0.7: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} @@ -3458,9 +3484,6 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} - clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -3577,6 +3600,10 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + crypto-hash@3.0.0: + resolution: {integrity: sha512-5l5xGtzuvGTU28GXxGV1JYVFou68buZWpkV1Fx5hIDRPnfbQ8KzabTlNIuDIeSCYGVPFehupzDqlnbXm2IXmdQ==} + engines: {node: '>=18'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3670,6 +3697,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@3.3.0: + resolution: {integrity: sha512-GRQOafGHwMHpjPx9iCvTgpu9NojZ49q794EEL94JVEw6VaeA8XTUyBKvAkOOjBX9oJNiV6G3P+T+tihFjo2TqA==} + engines: {node: '>=0.10.0'} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -3677,10 +3708,6 @@ packages: defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: - resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} - engines: {node: '>=10'} - define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -3716,6 +3743,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} engines: {node: '>=8'} @@ -4276,10 +4306,6 @@ packages: resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} engines: {node: '>=8'} - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -4360,10 +4386,6 @@ packages: gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - got@11.8.6: - resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} - engines: {node: '>=10.19.0'} - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4430,17 +4452,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} - http2-wrapper@1.0.3: - resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} - engines: {node: '>=10.19.0'} - https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -4651,6 +4666,10 @@ packages: resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} engines: {node: '>= 0.4'} + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4708,6 +4727,9 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + isomorphic-fetch@2.2.1: + resolution: {integrity: sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -4919,10 +4941,6 @@ packages: lower-case@1.1.4: resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} - lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} - lru-cache@10.2.0: resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} engines: {node: 14 || >=16.14} @@ -5013,10 +5031,6 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} - mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -5172,6 +5186,12 @@ packages: resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} engines: {node: '>=6.0.0'} + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + + node-fetch@1.7.3: + resolution: {integrity: sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -5203,10 +5223,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5278,6 +5294,9 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} + ofetch@1.3.4: + resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5311,10 +5330,6 @@ packages: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} - p-cancelable@2.1.1: - resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} - engines: {node: '>=8'} - p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -5582,10 +5597,6 @@ packages: queue-tick@1.0.1: resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -5718,9 +5729,6 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - resolve-alpn@1.2.1: - resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5736,9 +5744,6 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true - responselike@2.0.1: - resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} - restore-cursor@3.1.0: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} @@ -5751,6 +5756,9 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc4648@1.5.3: + resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==} + rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} deprecated: Rimraf versions prior to v4 are no longer supported @@ -5787,9 +5795,6 @@ packages: rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} - sabnzbd-api@1.5.0: - resolution: {integrity: sha512-bfiHGVBoLG5/BKyuLIIFbMHGKpZlGEinFPOlp7huUjRlJkB9vqoDD82Q5Dutd6yV1GwbZtUEcgYtGGNtqe0sMQ==} - safe-array-concat@1.1.0: resolution: {integrity: sha512-ZdQ0Jeb9Ofti4hbt5lX3T2JcAamT9hfzYU1MNB+z/jaEbB6wfFfPIR/zEORmZqobkCCJhSjodobH6WHNmJ97dg==} engines: {node: '>=0.4'} @@ -6392,11 +6397,18 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + uglify-js@3.17.4: resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} engines: {node: '>=0.8.0'} hasBin: true + uint8array-extras@1.4.0: + resolution: {integrity: sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==} + engines: {node: '>=18'} + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -6504,6 +6516,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + uuidjs@4.2.14: + resolution: {integrity: sha512-Z4iL8AWHlTWeAmi6v1TCPKBF5QkTxpdLDS2yrAm9cA9GvEwWFxnRm7uEEcDY5TrZuO2A/cLQyeuXjlohAMcCIQ==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -6511,6 +6527,10 @@ packages: resolution: {integrity: sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + validator@11.1.0: + resolution: {integrity: sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==} + engines: {node: '>= 0.10'} + video.js@8.16.1: resolution: {integrity: sha512-yAhxu4Vhyx5DdOgPn2PcRKHx3Vzs9tpvCWA0yX+sv5bIeBkg+IWdEX+MHGZgktgDQ/R8fJDxDbEASyvxXnFn1A==} @@ -6631,6 +6651,9 @@ packages: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -6753,6 +6776,9 @@ packages: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} + zipcodes-regex@1.0.3: + resolution: {integrity: sha512-ZZeokzfcmQDpu3nSDv6LqIcfsrsh5tlgpFeQdtzxlZupoMjxLwE9H5mZur/RBHsZdMwv6l2aELTLVq1lXCwURA==} + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -6947,6 +6973,47 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@ctrl/deluge@6.1.0': + dependencies: + '@ctrl/magnet-link': 4.0.2 + '@ctrl/shared-torrent': 6.0.0 + node-fetch-native: 1.6.4 + ofetch: 1.3.4 + tough-cookie: 4.1.4 + ufo: 1.5.4 + uint8array-extras: 1.4.0 + + '@ctrl/magnet-link@4.0.2': + dependencies: + rfc4648: 1.5.3 + uint8array-extras: 1.4.0 + + '@ctrl/qbittorrent@8.2.0': + dependencies: + '@ctrl/magnet-link': 4.0.2 + '@ctrl/shared-torrent': 6.0.0 + '@ctrl/torrent-file': 4.0.0 + cookie: 0.6.0 + node-fetch-native: 1.6.4 + ofetch: 1.3.4 + ufo: 1.5.4 + uint8array-extras: 1.4.0 + + '@ctrl/shared-torrent@6.0.0': {} + + '@ctrl/torrent-file@4.0.0': + dependencies: + crypto-hash: 3.0.0 + uint8array-extras: 1.4.0 + + '@ctrl/transmission@6.1.0': + dependencies: + '@ctrl/magnet-link': 4.0.2 + '@ctrl/shared-torrent': 6.0.0 + ofetch: 1.3.4 + ufo: 1.5.4 + uint8array-extras: 1.4.0 + '@dabh/diagnostics@2.0.3': dependencies: colorspace: 1.1.4 @@ -7258,6 +7325,12 @@ snapshots: '@istanbuljs/schema@0.1.3': {} + '@jc21/nzbget-jsonrpc-api@1.0.0': + dependencies: + '@nofrills/http': 3.5.12 + transitivePeerDependencies: + - supports-color + '@jellyfin/sdk@0.10.0(axios@1.7.2)': dependencies: axios: 1.7.2 @@ -7425,6 +7498,43 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@nofrills/collections@3.8.7': {} + + '@nofrills/http@3.5.12': + dependencies: + '@nofrills/lincoln-debug': 3.6.13 + '@nofrills/scrubs': 3.6.7 + isomorphic-fetch: 2.2.1 + transitivePeerDependencies: + - supports-color + + '@nofrills/lincoln-debug@3.6.13': + dependencies: + '@nofrills/collections': 3.8.7 + '@nofrills/lincoln': 3.5.9 + debug: 4.3.5 + transitivePeerDependencies: + - supports-color + + '@nofrills/lincoln@3.5.9': + dependencies: + '@nofrills/collections': 3.8.7 + uuidjs: 4.2.14 + + '@nofrills/scrubs@3.6.7': + dependencies: + '@nofrills/lincoln-debug': 3.6.13 + '@nofrills/types': 3.5.7 + deepmerge: 3.3.0 + transitivePeerDependencies: + - supports-color + + '@nofrills/types@3.5.7': + dependencies: + deepmerge: 3.3.0 + validator: 11.1.0 + zipcodes-regex: 1.0.3 + '@panva/hkdf@1.1.1': {} '@paralleldrive/cuid2@2.2.2': @@ -7502,8 +7612,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.13.0': optional: true - '@sindresorhus/is@4.6.0': {} - '@swc/counter@0.1.3': {} '@swc/helpers@0.5.5': @@ -7511,10 +7619,6 @@ snapshots: '@swc/counter': 0.1.3 tslib: 2.6.2 - '@szmarczak/http-timer@4.0.6': - dependencies: - defer-to-connect: 2.0.1 - '@t3-oss/env-core@0.10.1(typescript@5.5.3)(zod@3.23.8)': dependencies: zod: 3.23.8 @@ -7784,30 +7888,30 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466)': + '@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467)': dependencies: - '@trpc/server': 11.0.0-rc.466 + '@trpc/server': 11.0.0-rc.467 - '@trpc/next@11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/react-query@11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.466)(next@14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@trpc/next@11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/react-query@11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@trpc/server@11.0.0-rc.467)(next@14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@trpc/client': 11.0.0-rc.466(@trpc/server@11.0.0-rc.466) - '@trpc/server': 11.0.0-rc.466 + '@trpc/client': 11.0.0-rc.467(@trpc/server@11.0.0-rc.467) + '@trpc/server': 11.0.0-rc.467 next: 14.2.5(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.8) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: '@tanstack/react-query': 5.51.11(react@18.3.1) - '@trpc/react-query': 11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@trpc/react-query': 11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@trpc/react-query@11.0.0-rc.466(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.466(@trpc/server@11.0.0-rc.466))(@trpc/server@11.0.0-rc.466)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@trpc/react-query@11.0.0-rc.467(@tanstack/react-query@5.51.11(react@18.3.1))(@trpc/client@11.0.0-rc.467(@trpc/server@11.0.0-rc.467))(@trpc/server@11.0.0-rc.467)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/react-query': 5.51.11(react@18.3.1) - '@trpc/client': 11.0.0-rc.466(@trpc/server@11.0.0-rc.466) - '@trpc/server': 11.0.0-rc.466 + '@trpc/client': 11.0.0-rc.467(@trpc/server@11.0.0-rc.467) + '@trpc/server': 11.0.0-rc.467 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@trpc/server@11.0.0-rc.466': {} + '@trpc/server@11.0.0-rc.467': {} '@tsconfig/node10@1.0.9': {} @@ -7890,13 +7994,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 20.14.11 - '@types/cacheable-request@6.0.3': - dependencies: - '@types/http-cache-semantics': 4.0.4 - '@types/keyv': 3.1.4 - '@types/node': 20.14.11 - '@types/responselike': 1.0.3 - '@types/chroma-js@2.4.4': {} '@types/connect@3.4.38': @@ -7956,8 +8053,6 @@ snapshots: '@types/minimatch': 5.1.2 '@types/node': 20.14.11 - '@types/http-cache-semantics@4.0.4': {} - '@types/http-errors@2.0.4': {} '@types/inquirer@6.5.0': @@ -7971,10 +8066,6 @@ snapshots: '@types/keygrip@1.0.6': {} - '@types/keyv@3.1.4': - dependencies: - '@types/node': 20.14.11 - '@types/mime@1.3.5': {} '@types/mime@3.0.4': {} @@ -8012,10 +8103,6 @@ snapshots: '@types/prop-types': 15.7.11 csstype: 3.1.3 - '@types/responselike@1.0.3': - dependencies: - '@types/node': 20.14.11 - '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -8726,18 +8813,6 @@ snapshots: cac@6.7.14: {} - cacheable-lookup@5.0.4: {} - - cacheable-request@7.0.4: - dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.1.1 - keyv: 4.5.4 - lowercase-keys: 2.0.0 - normalize-url: 6.1.0 - responselike: 2.0.1 - call-bind@1.0.7: dependencies: es-define-property: 1.0.0 @@ -8846,10 +8921,6 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - clone-response@1.0.3: - dependencies: - mimic-response: 1.0.1 - clone@1.0.4: {} clsx@2.1.1: {} @@ -8965,6 +9036,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-hash@3.0.0: {} + cssesc@3.0.0: {} cssstyle@4.0.1: @@ -9053,14 +9126,14 @@ snapshots: deep-is@0.1.4: {} + deepmerge@3.3.0: {} + deepmerge@4.3.1: {} defaults@1.0.4: dependencies: clone: 1.0.4 - defer-to-connect@2.0.1: {} - define-data-property@1.1.4: dependencies: es-define-property: 1.0.0 @@ -9100,6 +9173,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.3: {} + detect-libc@2.0.2: {} detect-node-es@1.1.0: {} @@ -9208,7 +9283,6 @@ snapshots: encoding@0.1.13: dependencies: iconv-lite: 0.6.3 - optional: true end-of-stream@1.4.4: dependencies: @@ -9837,10 +9911,6 @@ snapshots: get-port@5.1.1: {} - get-stream@5.2.0: - dependencies: - pump: 3.0.0 - get-stream@6.0.1: {} get-stream@8.0.1: {} @@ -9950,20 +10020,6 @@ snapshots: dependencies: get-intrinsic: 1.2.4 - got@11.8.6: - dependencies: - '@sindresorhus/is': 4.6.0 - '@szmarczak/http-timer': 4.0.6 - '@types/cacheable-request': 6.0.3 - '@types/responselike': 1.0.3 - cacheable-lookup: 5.0.4 - cacheable-request: 7.0.4 - decompress-response: 6.0.0 - http2-wrapper: 1.0.3 - lowercase-keys: 2.0.0 - p-cancelable: 2.1.1 - responselike: 2.0.1 - graceful-fs@4.2.11: {} gradient-string@2.0.2: @@ -10023,8 +10079,6 @@ snapshots: html-escaper@2.0.2: {} - http-cache-semantics@4.1.1: {} - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.0 @@ -10032,11 +10086,6 @@ snapshots: transitivePeerDependencies: - supports-color - http2-wrapper@1.0.3: - dependencies: - quick-lru: 5.1.1 - resolve-alpn: 1.2.1 - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -10264,6 +10313,8 @@ snapshots: dependencies: call-bind: 1.0.7 + is-stream@1.1.0: {} + is-stream@2.0.1: {} is-stream@3.0.0: {} @@ -10309,6 +10360,11 @@ snapshots: isobject@3.0.1: {} + isomorphic-fetch@2.2.1: + dependencies: + node-fetch: 1.7.3 + whatwg-fetch: 3.6.20 + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -10544,8 +10600,6 @@ snapshots: lower-case@1.1.4: {} - lowercase-keys@2.0.0: {} - lru-cache@10.2.0: {} lru-cache@11.0.0: {} @@ -10632,8 +10686,6 @@ snapshots: mimic-fn@4.0.0: {} - mimic-response@1.0.1: {} - mimic-response@3.1.0: {} min-document@2.19.0: @@ -10780,6 +10832,13 @@ snapshots: dependencies: uuid: 8.3.2 + node-fetch-native@1.6.4: {} + + node-fetch@1.7.3: + dependencies: + encoding: 0.1.13 + is-stream: 1.1.0 + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -10813,8 +10872,6 @@ snapshots: normalize-path@3.0.0: {} - normalize-url@6.1.0: {} - npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -10901,6 +10958,12 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 + ofetch@1.3.4: + dependencies: + destr: 2.0.3 + node-fetch-native: 1.6.4 + ufo: 1.5.4 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -10953,8 +11016,6 @@ snapshots: os-tmpdir@1.0.2: {} - p-cancelable@2.1.1: {} - p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 @@ -11274,8 +11335,6 @@ snapshots: queue-tick@1.0.1: {} - quick-lru@5.1.1: {} - randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -11427,8 +11486,6 @@ snapshots: requires-port@1.0.0: {} - resolve-alpn@1.2.1: {} - resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11445,10 +11502,6 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - responselike@2.0.1: - dependencies: - lowercase-keys: 2.0.0 - restore-cursor@3.1.0: dependencies: onetime: 5.1.2 @@ -11458,6 +11511,8 @@ snapshots: reusify@1.0.4: {} + rfc4648@1.5.3: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -11505,11 +11560,6 @@ snapshots: dependencies: tslib: 2.6.2 - sabnzbd-api@1.5.0: - dependencies: - form-data: 4.0.0 - got: 11.8.6 - safe-array-concat@1.1.0: dependencies: call-bind: 1.0.7 @@ -12175,9 +12225,13 @@ snapshots: uc.micro@2.1.0: {} + ufo@1.5.4: {} + uglify-js@3.17.4: optional: true + uint8array-extras@1.4.0: {} + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.7 @@ -12269,12 +12323,16 @@ snapshots: uuid@9.0.1: {} + uuidjs@4.2.14: {} + v8-compile-cache-lib@3.0.1: {} validate-npm-package-name@5.0.0: dependencies: builtins: 5.0.1 + validator@11.1.0: {} + video.js@8.16.1: dependencies: '@babel/runtime': 7.23.9 @@ -12432,6 +12490,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.0.0: @@ -12581,4 +12641,6 @@ snapshots: compress-commons: 4.1.2 readable-stream: 3.6.2 + zipcodes-regex@1.0.3: {} + zod@3.23.8: {}