diff --git a/.gitignore b/.gitignore index e81335cbf..91c18132f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ yarn-error.log* apps/tasks/tasks.cjs apps/websocket/wssServer.cjs apps/nextjs/.million/ + + +#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 84dd7000b..0263499bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,11 +9,17 @@ "js/ts.implicitProjectConfig.experimentalDecorators": true, "prettier.configPath": "./tooling/prettier/index.mjs", "cSpell.words": [ + "ajnart", "cqmin", + "gridstack", "homarr", "jellyfin", "mantine", + "manuel-rw", + "Meierschlumpf", "overseerr", + "Sabnzbd", + "SeDemal", "Sonarr", "superjson", "tabler", diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index fe56c11ea..6a6be9b42 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -4,6 +4,9 @@ import { TRPCError } from "@trpc/server"; // Placed here because gridstack styles are used for board content import "~/styles/gridstack.scss"; +import { IntegrationProvider } from "@homarr/auth/client"; +import { auth } from "@homarr/auth/next"; +import { getIntegrationsWithPermissionsAsync } from "@homarr/auth/server"; import { getI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; @@ -27,8 +30,16 @@ export const createBoardContentPage = >( getInitialBoardAsync: getInitialBoard, isBoardContentPage: true, }), - page: () => { - return ; + // eslint-disable-next-line no-restricted-syntax + page: async () => { + const session = await auth(); + const integrations = await getIntegrationsWithPermissionsAsync(session); + + return ( + + + + ); }, generateMetadataAsync: async ({ params }: { params: TParams }): Promise => { try { diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx index 7d4b436d2..3dcc78ab2 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/page.tsx @@ -18,8 +18,7 @@ interface NewIntegrationPageProps { } export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind); + const result = z.enum(integrationKinds).safeParse(searchParams.kind); if (!result.success) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx index 804858768..293a8fa96 100644 --- a/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx +++ b/apps/nextjs/src/app/[locale]/manage/settings/_components/setting-switch.tsx @@ -3,6 +3,7 @@ import React from "react"; import type { MantineSpacing } from "@mantine/core"; import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core"; +import type { Modify } from "@homarr/common/types"; import type { UseFormReturnType } from "@homarr/form"; export const SwitchSetting = >({ @@ -13,9 +14,12 @@ export const SwitchSetting = >({ formKey, disabled, }: { - form: Omit TFormValue>, "setFieldValue"> & { - setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; - }; + form: Modify< + UseFormReturnType TFormValue>, + { + setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void; + } + >; formKey: keyof TFormValue; ms?: MantineSpacing; title: string; 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/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index d30ac2fed..4d452574e 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; +import type { Modify } from "@homarr/common/types"; import { createId } from "@homarr/db/client"; import type { WidgetKind } from "@homarr/definitions"; import type { BoardItemAdvancedOptions } from "@homarr/validation"; @@ -71,9 +72,12 @@ export const useItemActions = () => { advancedOptions: { customCssClasses: [], }, - } satisfies Omit & { - kind: WidgetKind; - }; + } satisfies Modify< + Omit, + { + kind: WidgetKind; + } + >; return { ...previous, @@ -105,7 +109,7 @@ export const useItemActions = () => { id: createId(), yOffset: undefined, xOffset: undefined, - } satisfies Omit & { yOffset?: number; xOffset?: number }; + } satisfies Modify; return { ...previous, diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 28636d32b..2af6b3154 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -10,6 +10,7 @@ import { WidgetError } from "@homarr/widgets/errors"; import type { Item } from "~/app/[locale]/boards/_types"; import { useEditMode, useRequiredBoard } from "~/app/[locale]/boards/(content)/_context"; import classes from "../sections/item.module.css"; +import { useItemActions } from "./item-actions"; import { BoardItemMenu } from "./item-menu"; interface BoardItemContentProps { @@ -56,6 +57,9 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { const Comp = loadWidgetDynamic(item.kind); const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options); const newItem = { ...item, options }; + const { updateItemOptions } = useItemActions(); + const updateOptions = ({ newOptions }: { newOptions: Record }) => + updateItemOptions({ itemId: item.id, newOptions }); if (!serverData?.isReady) return null; @@ -80,6 +84,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { 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/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index be91fecbd..78b3326c9 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -3,7 +3,8 @@ import { TRPCError } from "@trpc/server"; import type { Session } from "@homarr/auth"; import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server"; import { constructIntegrationPermissions } from "@homarr/auth/shared"; -import { decryptSecret } from "@homarr/common"; +import { decryptSecret } from "@homarr/common/server"; +import type { AtLeastOneOf } from "@homarr/common/types"; import type { Database } from "@homarr/db"; import { and, eq, inArray } from "@homarr/db"; import { integrations } from "@homarr/db/schema/sqlite"; @@ -12,7 +13,7 @@ import { z } from "@homarr/validation"; import { publicProcedure } from "../trpc"; -type IntegrationAction = "query" | "interact"; +export type IntegrationAction = "query" | "interact"; /** * Creates a middleware that provides the integration in the context that is of the specified kinds @@ -25,7 +26,7 @@ type IntegrationAction = "query" | "interact"; */ export const createOneIntegrationMiddleware = ( action: IntegrationAction, - ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided + ...kinds: AtLeastOneOf // Ensure at least one kind is provided ) => { return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { const integration = await ctx.db.query.integrations.findFirst({ @@ -95,7 +96,7 @@ export const createOneIntegrationMiddleware = ( */ export const createManyIntegrationMiddleware = ( action: IntegrationAction, - ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided + ...kinds: AtLeastOneOf // Ensure at least one kind is provided ) => { return publicProcedure .input(z.object({ integrationIds: z.array(z.string()).min(1) })) @@ -161,7 +162,7 @@ export const createManyIntegrationMiddleware = ( */ export const createManyIntegrationOfOneItemMiddleware = ( action: IntegrationAction, - ...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided + ...kinds: AtLeastOneOf // Ensure at least one kind is provided ) => { return publicProcedure .input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() })) diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 60040f457..5ac6c4350 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -1,6 +1,6 @@ import { TRPCError } from "@trpc/server"; -import { decryptSecret, encryptSecret } from "@homarr/common"; +import { decryptSecret, encryptSecret } from "@homarr/common/server"; import type { Database } from "@homarr/db"; import { and, createId, eq, inArray } from "@homarr/db"; import { diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts index 673d2f6b4..91d12f344 100644 --- a/packages/api/src/router/integration/integration-test-connection.ts +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -1,8 +1,8 @@ -import { decryptSecret } from "@homarr/common"; +import { decryptSecret } from "@homarr/common/server"; import type { Integration } from "@homarr/db/schema/sqlite"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions"; -import { integrationCreatorByKind, IntegrationTestConnectionError } from "@homarr/integrations"; +import { integrationCreator, IntegrationTestConnectionError } from "@homarr/integrations"; type FormIntegration = Integration & { secrets: { @@ -37,23 +37,25 @@ export const testConnectionAsync = async ( const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets]; const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets); - const filteredSecrets = secretKinds.map((kind) => { - const secrets = sourcedSecrets.filter((secret) => secret.kind === kind); - // Will never be undefined because of the check before - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (secrets.length === 1) return secrets[0]!; + const decryptedSecrets = secretKinds + .map((kind) => { + const secrets = sourcedSecrets.filter((secret) => secret.kind === kind); + // Will never be undefined because of the check before + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (secrets.length === 1) return secrets[0]!; - // There will always be a matching secret because of the getSecretKindOption function - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return secrets.find((secret) => secret.source === "form") ?? secrets[0]!; - }); + // There will always be a matching secret because of the getSecretKindOption function + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return secrets.find((secret) => secret.source === "form") ?? secrets[0]!; + }) + .map(({ source: _, ...secret }) => secret); - // @ts-expect-error - For now we expect an error here as not all integerations have been implemented - const integrationInstance = integrationCreatorByKind(integration.kind, { - id: integration.id, - name: integration.name, - url: integration.url, - decryptedSecrets: filteredSecrets, + const { secrets: _, ...baseIntegration } = integration; + + // @ts-expect-error - For now we expect an error here as not all integrations have been implemented + const integrationInstance = integrationCreator({ + ...baseIntegration, + decryptedSecrets, }); await integrationInstance.testConnectionAsync(); diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts index dde896352..52a23f3b6 100644 --- a/packages/api/src/router/test/integration/integration-router.spec.ts +++ b/packages/api/src/router/test/integration/integration-router.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; -import { encryptSecret } from "@homarr/common"; +import { encryptSecret } from "@homarr/common/server"; import { createId } from "@homarr/db"; import { integrations, integrationSecrets } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts index 5118f526c..e692543b6 100644 --- a/packages/api/src/router/test/integration/integration-test-connection.spec.ts +++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts @@ -5,9 +5,9 @@ import * as homarrIntegrations from "@homarr/integrations"; import { testConnectionAsync } from "../../integration/integration-test-connection"; -vi.mock("@homarr/common", async (importActual) => { +vi.mock("@homarr/common/server", async (importActual) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports - const actual = await importActual(); + const actual = await importActual(); return { ...actual, @@ -18,7 +18,7 @@ vi.mock("@homarr/common", async (importActual) => { describe("testConnectionAsync should run test connection of integration", () => { test("with input of only form secrets matching api key kind it should use form apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind"); + const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue({ testConnectionAsync: async () => await Promise.resolve(), @@ -42,10 +42,11 @@ describe("testConnectionAsync should run test connection of integration", () => await testConnectionAsync(integration); // Assert - expect(factorySpy).toHaveBeenCalledWith("piHole", { + expect(factorySpy).toHaveBeenCalledWith({ id: "new", name: "Pi Hole", url: "http://pi.hole", + kind: "piHole", decryptedSecrets: [ expect.objectContaining({ kind: "apiKey", @@ -57,7 +58,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind"); + const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue({ testConnectionAsync: async () => await Promise.resolve(), @@ -88,10 +89,11 @@ describe("testConnectionAsync should run test connection of integration", () => await testConnectionAsync(integration, dbSecrets); // Assert - expect(factorySpy).toHaveBeenCalledWith("piHole", { + expect(factorySpy).toHaveBeenCalledWith({ id: "new", name: "Pi Hole", url: "http://pi.hole", + kind: "piHole", decryptedSecrets: [ expect.objectContaining({ kind: "apiKey", @@ -103,7 +105,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of form and db secrets matching api key kind it should use form apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind"); + const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue({ testConnectionAsync: async () => await Promise.resolve(), @@ -134,10 +136,11 @@ describe("testConnectionAsync should run test connection of integration", () => await testConnectionAsync(integration, dbSecrets); // Assert - expect(factorySpy).toHaveBeenCalledWith("piHole", { + expect(factorySpy).toHaveBeenCalledWith({ id: "new", name: "Pi Hole", url: "http://pi.hole", + kind: "piHole", decryptedSecrets: [ expect.objectContaining({ kind: "apiKey", @@ -149,7 +152,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind"); + const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue({ testConnectionAsync: async () => await Promise.resolve(), @@ -184,10 +187,11 @@ describe("testConnectionAsync should run test connection of integration", () => await testConnectionAsync(integration, dbSecrets); // Assert - expect(factorySpy).toHaveBeenCalledWith("piHole", { + expect(factorySpy).toHaveBeenCalledWith({ id: "new", name: "Pi Hole", url: "http://pi.hole", + kind: "piHole", decryptedSecrets: [ expect.objectContaining({ kind: "apiKey", @@ -199,7 +203,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreatorByKind"); + const factorySpy = vi.spyOn(homarrIntegrations, "integrationCreator"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue({ testConnectionAsync: async () => await Promise.resolve(), @@ -234,10 +238,11 @@ describe("testConnectionAsync should run test connection of integration", () => await testConnectionAsync(integration, dbSecrets); // Assert - expect(factorySpy).toHaveBeenCalledWith("piHole", { + expect(factorySpy).toHaveBeenCalledWith({ id: "new", name: "Pi Hole", url: "http://pi.hole", + kind: "piHole", decryptedSecrets: [ expect.objectContaining({ kind: "username", diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 1dba2888f..606ec04b7 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,3 +1,4 @@ +import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { CalendarEvent } from "@homarr/integrations/types"; import { createItemAndIntegrationChannel } from "@homarr/redis"; @@ -6,7 +7,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure - .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "sonarr", "radarr", "readarr", "lidarr")) + .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.flatMap(async (integration) => { diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index f12c36edb..41c09c1b2 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,6 +1,7 @@ import { TRPCError } from "@trpc/server"; -import { AdGuardHomeIntegration, PiHoleIntegration } from "@homarr/integrations"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; import type { DnsHoleSummary } from "@homarr/integrations/types"; import { logger } from "@homarr/log"; import { createCacheChannel } from "@homarr/redis"; @@ -11,21 +12,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; export const dnsHoleRouter = createTRPCRouter({ summary: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "piHole", "adGuardHome")) + .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { const cache = createCacheChannel(`dns-hole-summary:${integration.id}`); const { data } = await cache.consumeAsync(async () => { - let client; - switch (integration.kind) { - case "piHole": - client = new PiHoleIntegration(integration); - break; - case "adGuardHome": - client = new AdGuardHomeIntegration(integration); - break; - } + const client = integrationCreator(integration); return await client.getSummaryAsync().catch((err) => { logger.error("dns-hole router - ", err); @@ -47,33 +40,17 @@ export const dnsHoleRouter = createTRPCRouter({ }), enable: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome")) - .mutation(async ({ ctx }) => { - let client; - switch (ctx.integration.kind) { - case "piHole": - client = new PiHoleIntegration(ctx.integration); - break; - case "adGuardHome": - client = new AdGuardHomeIntegration(ctx.integration); - break; - } + .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) + .mutation(async ({ ctx: { integration } }) => { + const client = integrationCreator(integration); await client.enableAsync(); }), disable: publicProcedure .input(controlsInputSchema) - .unstable_concat(createOneIntegrationMiddleware("interact", "piHole", "adGuardHome")) - .mutation(async ({ ctx, input }) => { - let client; - switch (ctx.integration.kind) { - case "piHole": - client = new PiHoleIntegration(ctx.integration); - break; - case "adGuardHome": - client = new AdGuardHomeIntegration(ctx.integration); - break; - } + .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) + .mutation(async ({ ctx: { integration }, input }) => { + const client = integrationCreator(integration); await client.disableAsync(input.duration); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts new file mode 100644 index 000000000..927a0c438 --- /dev/null +++ b/packages/api/src/router/widgets/downloads.ts @@ -0,0 +1,110 @@ +import { observable } from "@trpc/server/observable"; + +import type { Integration } from "@homarr/db/schema/sqlite"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; +import { downloadClientItemSchema, integrationCreator } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; +import { z } from "@homarr/validation"; + +import type { IntegrationAction } from "../../middlewares/integration"; +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; + +const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => + createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient")); + +export const downloadsRouter = createTRPCRouter({ + getJobsAndStatuses: publicProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("query")) + .query(async ({ ctx }) => { + return await Promise.all( + ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => { + const channel = createItemAndIntegrationChannel("downloads", integration.id); + const { data, timestamp } = (await channel.getAsync()) ?? { data: null, timestamp: new Date(0) }; + return { + integration, + timestamp, + data, + }; + }), + ); + }), + subscribeToJobsAndStatuses: publicProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("query")) + .subscription(({ ctx }) => { + return observable<{ integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integrationWithSecrets of ctx.integrations) { + const { decryptedSecrets: _, ...integration } = integrationWithSecrets; + const channel = createItemAndIntegrationChannel("downloads", integration.id); + const unsubscribe = channel.subscribe((data) => { + emit.next({ + integration, + timestamp: new Date(), + data, + }); + }); + unsubscribes.push(unsubscribe); + } + return () => { + unsubscribes.forEach((unsubscribe) => { + unsubscribe(); + }); + }; + }); + }), + pause: protectedProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("interact")) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = integrationCreator(integration); + await integrationInstance.pauseQueueAsync(); + }), + ); + }), + pauseItem: protectedProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("interact")) + .input(z.object({ item: downloadClientItemSchema })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = integrationCreator(integration); + await integrationInstance.pauseItemAsync(input.item); + }), + ); + }), + resume: protectedProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("interact")) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = integrationCreator(integration); + await integrationInstance.resumeQueueAsync(); + }), + ); + }), + resumeItem: protectedProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("interact")) + .input(z.object({ item: downloadClientItemSchema })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = integrationCreator(integration); + await integrationInstance.resumeItemAsync(input.item); + }), + ); + }), + deleteItem: protectedProcedure + .unstable_concat(createDownloadClientIntegrationMiddleware("interact")) + .input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const integrationInstance = integrationCreator(integration); + await integrationInstance.deleteItemAsync(input.item, input.fromDisk); + }), + ); + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index dce122e9b..b8b221536 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc"; import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; +import { downloadsRouter } from "./downloads"; import { indexerManagerRouter } from "./indexer-manager"; import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; @@ -18,6 +19,7 @@ export const widgetRouter = createTRPCRouter({ smartHome: smartHomeRouter, mediaServer: mediaServerRouter, calendar: calendarRouter, + downloads: downloadsRouter, mediaRequests: mediaRequestsRouter, rssFeed: rssFeedRouter, indexerManager: indexerManagerRouter, diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 9321f1074..731216f0c 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -1,21 +1,26 @@ import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; -import { integrationCreatorByKind } from "@homarr/integrations"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; import type { Indexer } from "@homarr/integrations/types"; import { logger } from "@homarr/log"; import { createItemAndIntegrationChannel } from "@homarr/redis"; +import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => + createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager")); + export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "prowlarr")) + .unstable_concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { const results = await Promise.all( ctx.integrations.map(async (integration) => { - const client = integrationCreatorByKind(integration.kind, integration); + const client = integrationCreator(integration); const indexers = await client.getIndexersAsync().catch((err) => { logger.error("indexer-manager router - ", err); throw new TRPCError({ @@ -34,7 +39,7 @@ export const indexerManagerRouter = createTRPCRouter({ }), subscribeIndexersStatus: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "prowlarr")) + .unstable_concat(createIndexerManagerIntegrationMiddleware("query")) .subscription(({ ctx }) => { return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => { const unsubscribes: (() => void)[] = []; @@ -57,11 +62,11 @@ export const indexerManagerRouter = createTRPCRouter({ }), testAllIndexers: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("interact", "prowlarr")) + .unstable_concat(createIndexerManagerIntegrationMiddleware("interact")) .mutation(async ({ ctx }) => { await Promise.all( ctx.integrations.map(async (integration) => { - const client = integrationCreatorByKind(integration.kind, integration); + const client = integrationCreator(integration); await client.testAllAsync().catch((err) => { logger.error("indexer-manager router - ", err); throw new TRPCError({ diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 4a289e763..2c2916715 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,5 +1,6 @@ +import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; -import { integrationCreatorByKind } from "@homarr/integrations"; +import { integrationCreator } from "@homarr/integrations"; import { createItemAndIntegrationChannel } from "@homarr/redis"; import { z } from "@homarr/validation"; @@ -11,7 +12,9 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: publicProcedure - .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr")) + .unstable_concat( + createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), + ) .query(async ({ input }) => { return await Promise.all( input.integrationIds.map(async (integrationId) => { @@ -21,7 +24,9 @@ export const mediaRequestsRouter = createTRPCRouter({ ); }), getStats: publicProcedure - .unstable_concat(createManyIntegrationOfOneItemMiddleware("query", "overseerr", "jellyseerr")) + .unstable_concat( + createManyIntegrationOfOneItemMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")), + ) .query(async ({ input }) => { return await Promise.all( input.integrationIds.map(async (integrationId) => { @@ -34,15 +39,15 @@ export const mediaRequestsRouter = createTRPCRouter({ ); }), answerRequest: protectedProcedure - .unstable_concat(createOneIntegrationMiddleware("interact", "overseerr", "jellyseerr")) + .unstable_concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) })) - .mutation(async ({ ctx, input }) => { - const integration = integrationCreatorByKind(ctx.integration.kind, ctx.integration); + .mutation(async ({ ctx: { integration }, input }) => { + const integrationInstance = integrationCreator(integration); if (input.answer === "approve") { - await integration.approveRequestAsync(input.requestId); + await integrationInstance.approveRequestAsync(input.requestId); return; } - await integration.declineRequestAsync(input.requestId); + await integrationInstance.declineRequestAsync(input.requestId); }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 00f4cf68c..ec93ad206 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -1,14 +1,19 @@ import { observable } from "@trpc/server/observable"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; import { createItemAndIntegrationChannel } from "@homarr/redis"; +import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => + createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService")); + export const mediaServerRouter = createTRPCRouter({ getCurrentStreams: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex")) + .unstable_concat(createMediaServerIntegrationMiddleware("query")) .query(async ({ ctx }) => { return await Promise.all( ctx.integrations.map(async (integration) => { @@ -22,7 +27,7 @@ export const mediaServerRouter = createTRPCRouter({ ); }), subscribeToCurrentStreams: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", "jellyfin", "plex")) + .unstable_concat(createMediaServerIntegrationMiddleware("query")) .subscription(({ ctx }) => { return observable<{ integrationId: string; data: StreamSession[] }>((emit) => { const unsubscribes: (() => void)[] = []; diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index b77a5d051..3cfd3a98a 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,12 +1,17 @@ import { observable } from "@trpc/server/observable"; -import { HomeAssistantIntegration } from "@homarr/integrations"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { integrationCreator } from "@homarr/integrations"; import { homeAssistantEntityState } from "@homarr/redis"; import { z } from "@homarr/validation"; +import type { IntegrationAction } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) => + createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer")); + export const smartHomeRouter = createTRPCRouter({ subscribeEntityState: publicProcedure.input(z.object({ entityId: z.string() })).subscription(({ input }) => { return observable<{ @@ -26,17 +31,17 @@ export const smartHomeRouter = createTRPCRouter({ }); }), switchEntity: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant")) + .unstable_concat(createSmartHomeIntegrationMiddleware("interact")) .input(z.object({ entityId: z.string() })) - .mutation(async ({ ctx, input }) => { - const client = new HomeAssistantIntegration(ctx.integration); + .mutation(async ({ ctx: { integration }, input }) => { + const client = integrationCreator(integration); return await client.triggerToggleAsync(input.entityId); }), executeAutomation: publicProcedure - .unstable_concat(createOneIntegrationMiddleware("interact", "homeAssistant")) + .unstable_concat(createSmartHomeIntegrationMiddleware("interact")) .input(z.object({ automationId: z.string() })) - .mutation(async ({ input, ctx }) => { - const client = new HomeAssistantIntegration(ctx.integration); + .mutation(async ({ ctx: { integration }, input }) => { + const client = integrationCreator(integration); await client.triggerAutomationAsync(input.automationId); }), }); diff --git a/packages/auth/client.ts b/packages/auth/client.ts index ab914304f..0e543ce6c 100644 --- a/packages/auth/client.ts +++ b/packages/auth/client.ts @@ -1 +1,2 @@ export { signIn, signOut, useSession, SessionProvider } from "next-auth/react"; +export * from "./permissions/integration-provider"; diff --git a/packages/auth/permissions/integration-permissions.ts b/packages/auth/permissions/integration-permissions.ts index d8c4d2127..0ab30322d 100644 --- a/packages/auth/permissions/integration-permissions.ts +++ b/packages/auth/permissions/integration-permissions.ts @@ -13,14 +13,14 @@ export interface IntegrationPermissionsProps { export const constructIntegrationPermissions = (integration: IntegrationPermissionsProps, session: Session | null) => { return { - hasFullAccess: session?.user.permissions.includes("integration-full-all"), + hasFullAccess: session?.user.permissions.includes("integration-full-all") ?? false, hasInteractAccess: integration.userPermissions.some(({ permission }) => permission === "interact") || integration.groupPermissions.some(({ permission }) => permission === "interact") || - session?.user.permissions.includes("integration-interact-all"), + (session?.user.permissions.includes("integration-interact-all") ?? false), hasUseAccess: integration.userPermissions.length >= 1 || integration.groupPermissions.length >= 1 || - session?.user.permissions.includes("integration-use-all"), + (session?.user.permissions.includes("integration-use-all") ?? false), }; }; diff --git a/packages/auth/permissions/integration-provider.tsx b/packages/auth/permissions/integration-provider.tsx new file mode 100644 index 000000000..f1f8eef9a --- /dev/null +++ b/packages/auth/permissions/integration-provider.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useContext } from "react"; + +interface IntegrationContextProps { + integrations: { + id: string; + name: string; + url: string; + kind: string; + permissions: { + hasFullAccess: boolean; + hasInteractAccess: boolean; + hasUseAccess: boolean; + }; + }[]; +} + +const IntegrationContext = createContext(null); + +export const IntegrationProvider = ({ integrations, children }: PropsWithChildren) => { + return {children}; +}; + +export const useIntegrationsWithUseAccess = () => { + const context = useContext(IntegrationContext); + + if (!context) { + throw new Error("useIntegrationsWithUseAccess must be used within an IntegrationProvider"); + } + + return context.integrations.filter((integration) => integration.permissions.hasUseAccess); +}; + +export const useIntegrationsWithInteractAccess = () => { + const context = useContext(IntegrationContext); + + if (!context) { + throw new Error("useIntegrationsWithInteractAccess must be used within an IntegrationProvider"); + } + + return context.integrations.filter((integration) => integration.permissions.hasInteractAccess); +}; + +export const useIntegrationsWithFullAccess = () => { + const context = useContext(IntegrationContext); + + if (!context) { + throw new Error("useIntegrationsWithFullAccess must be used within an IntegrationProvider"); + } + + return context.integrations.filter((integration) => integration.permissions.hasFullAccess); +}; diff --git a/packages/auth/permissions/integrations-with-permissions.ts b/packages/auth/permissions/integrations-with-permissions.ts new file mode 100644 index 000000000..98a25be66 --- /dev/null +++ b/packages/auth/permissions/integrations-with-permissions.ts @@ -0,0 +1,36 @@ +import type { Session } from "@auth/core/types"; + +import { db, eq, inArray } from "@homarr/db"; +import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema/sqlite"; + +import { constructIntegrationPermissions } from "./integration-permissions"; + +export const getIntegrationsWithPermissionsAsync = async (session: Session | null) => { + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, session?.user.id ?? ""), + }); + const integrations = await db.query.integrations.findMany({ + columns: { + id: true, + name: true, + url: true, + kind: true, + }, + with: { + userPermissions: { + where: eq(integrationUserPermissions.userId, session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map((group) => group.groupId), + ), + }, + }, + }); + + return integrations.map(({ userPermissions, groupPermissions, ...integration }) => ({ + ...integration, + permissions: constructIntegrationPermissions({ userPermissions, groupPermissions }, session), + })); +}; diff --git a/packages/auth/server.ts b/packages/auth/server.ts index ae11f0f60..903902f62 100644 --- a/packages/auth/server.ts +++ b/packages/auth/server.ts @@ -1,2 +1,3 @@ export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions"; +export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions"; export { isProviderEnabled } from "./providers/check-provider"; diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 45141ab45..f45300e32 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -7,5 +7,4 @@ export * from "./hooks"; export * from "./url"; export * from "./number"; export * from "./error"; -export * from "./encryption"; export * from "./fetch-with-timeout"; diff --git a/packages/common/src/number.ts b/packages/common/src/number.ts index 24738850d..bb479f34c 100644 --- a/packages/common/src/number.ts +++ b/packages/common/src/number.ts @@ -19,3 +19,31 @@ 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 should be an integer. + * Will return "NaI" and logs a warning if a float is passed. + * Concat as parameters so it is not added if the returned value is "NaI" or "∞". + * Returns "∞" if the size is too large to be represented in the current format. + */ +export const humanFileSize = (size: number, concat = ""): string => { + //64bit limit for Number stops at EiB + const siRanges = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; + if (!Number.isInteger(size)) { + console.warn( + "Invalid use of the humanFileSize function with a float, please report this and what integration this is impacting.", + ); + //Not an Integer + return "NaI"; + } + let count = 0; + while (count < siRanges.length) { + const tempSize = size / Math.pow(1024, count); + if (tempSize < 1024) { + return tempSize.toFixed(Math.min(count, 1)) + siRanges[count] + concat; + } + count++; + } + return "∞"; +}; diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index 8916238d4..136ecfc84 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -1,2 +1,3 @@ export * from "./app-url/server"; export * from "./security"; +export * from "./encryption"; diff --git a/packages/common/src/types.ts b/packages/common/src/types.ts index 9cd354b34..e29902783 100644 --- a/packages/common/src/types.ts +++ b/packages/common/src/types.ts @@ -1 +1,7 @@ export type MaybePromise = T | Promise; + +export type AtLeastOneOf = [T, ...T[]]; + +export type Modify>> = { + [P in keyof (Omit & R)]: (Omit & R)[P]; +}; diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index 94a57a0de..f6289859e 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -1,5 +1,6 @@ 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 { indexerManagerJob } from "./jobs/integrations/indexer-manager"; import { mediaOrganizerJob } from "./jobs/integrations/media-organizer"; @@ -17,6 +18,7 @@ export const jobGroup = createCronJobGroup({ smartHomeEntityState: smartHomeEntityStateJob, mediaServer: mediaServerJob, mediaOrganizer: mediaOrganizerJob, + downloads: downloadsJob, mediaRequests: mediaRequestsJob, rssFeeds: rssFeedsJob, indexerManager: indexerManagerJob, 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..35ff36277 --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/downloads.ts @@ -0,0 +1,27 @@ +import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createCronJob } from "../../lib"; + +export const downloadsJob = createCronJob("downloads", EVERY_5_SECONDS).withCallback(async () => { + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["downloads"], + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const { integration } of itemForIntegration.integrations) { + const integrationInstance = integrationCreatorFromSecrets(integration); + await integrationInstance + .getClientJobsAndStatusAsync() + .then(async (data) => { + const channel = createItemAndIntegrationChannel("downloads", integration.id); + await channel.publishAndUpdateLastStateAsync(data); + }) + .catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`)); + } + } +}); diff --git a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts index ddb1919b1..782bb5bbc 100644 --- a/packages/cron-jobs/src/jobs/integrations/home-assistant.ts +++ b/packages/cron-jobs/src/jobs/integrations/home-assistant.ts @@ -1,10 +1,9 @@ import SuperJSON from "superjson"; -import { decryptSecret } from "@homarr/common"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema/sqlite"; -import { HomeAssistantIntegration } from "@homarr/integrations"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; import { logger } from "@homarr/log"; import { homeAssistantEntityState } from "@homarr/redis"; @@ -13,24 +12,8 @@ import type { WidgetComponentProps } from "../../../../widgets"; import { createCronJob } from "../../lib"; export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await db.query.items.findMany({ - where: eq(items.kind, "smartHome-entityState"), - with: { - integrations: { - with: { - integration: { - with: { - secrets: { - columns: { - kind: true, - value: true, - }, - }, - }, - }, - }, - }, - }, + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["smartHome-entityState"], }); for (const itemForIntegration of itemsForIntegration) { @@ -43,13 +26,7 @@ export const smartHomeEntityStateJob = createCronJob("smartHomeEntityState", EVE itemForIntegration.options, ); - const homeAssistant = new HomeAssistantIntegration({ - ...integration, - decryptedSecrets: integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }); + const homeAssistant = integrationCreatorFromSecrets(integration); const state = await homeAssistant.getEntityStateAsync(options.entityId); if (!state.success) { diff --git a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts index 9d933afbc..858e0c84a 100644 --- a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts +++ b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts @@ -1,42 +1,19 @@ -import { decryptSecret } from "@homarr/common"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema/sqlite"; -import { ProwlarrIntegration } from "@homarr/integrations"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; import { createCronJob } from "../../lib"; export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await db.query.items.findMany({ - where: eq(items.kind, "indexerManager"), - with: { - integrations: { - with: { - integration: { - with: { - secrets: { - columns: { - kind: true, - value: true, - }, - }, - }, - }, - }, - }, - }, + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["indexerManager"], }); for (const itemForIntegration of itemsForIntegration) { - for (const integration of itemForIntegration.integrations) { - const prowlarr = new ProwlarrIntegration({ - ...integration.integration, - decryptedSecrets: integration.integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }); - await prowlarr.getIndexersAsync(); + for (const { integration } of itemForIntegration.integrations) { + const integrationInstance = integrationCreatorFromSecrets(integration); + await integrationInstance.getIndexersAsync(); } } }); diff --git a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts index 1403d9742..be088b448 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-organizer.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-organizer.ts @@ -1,11 +1,11 @@ import dayjs from "dayjs"; import SuperJSON from "superjson"; -import { decryptSecret } from "@homarr/common"; +import type { Modify } from "@homarr/common/types"; import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; -import { db, eq } from "@homarr/db"; -import { items } from "@homarr/db/schema/sqlite"; -import { integrationCreatorByKind } from "@homarr/integrations"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; import type { CalendarEvent } from "@homarr/integrations/types"; import { createItemAndIntegrationChannel } from "@homarr/redis"; @@ -14,46 +14,25 @@ import type { WidgetComponentProps } from "../../../../widgets"; import { createCronJob } from "../../lib"; export const mediaOrganizerJob = createCronJob("mediaOrganizer", EVERY_MINUTE).withCallback(async () => { - const itemsForIntegration = await db.query.items.findMany({ - where: eq(items.kind, "calendar"), - with: { - integrations: { - with: { - integration: { - with: { - secrets: { - columns: { - kind: true, - value: true, - }, - }, - }, - }, - }, - }, - }, + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["calendar"], }); for (const itemForIntegration of itemsForIntegration) { - for (const integration of itemForIntegration.integrations) { + for (const { integration } of itemForIntegration.integrations) { const options = SuperJSON.parse["options"]>(itemForIntegration.options); const start = dayjs().subtract(Number(options.filterPastMonths), "months").toDate(); const end = dayjs().add(Number(options.filterFutureMonths), "months").toDate(); - const decryptedSecrets = integration.integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })); - - const integrationInstance = integrationCreatorByKind(integration.integration.kind as "radarr" | "sonarr", { - ...integration.integration, - decryptedSecrets, - }); + //Asserting the integration kind until all of them get implemented + const integrationInstance = integrationCreatorFromSecrets( + integration as Modify, + ); const events = await integrationInstance.getCalendarEventsAsync(start, end); - const cache = createItemAndIntegrationChannel("calendar", integration.integrationId); + const cache = createItemAndIntegrationChannel("calendar", integration.id); await cache.setAsync(events); } } diff --git a/packages/cron-jobs/src/jobs/integrations/media-requests.ts b/packages/cron-jobs/src/jobs/integrations/media-requests.ts index c97645bd5..8e34d7305 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-requests.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-requests.ts @@ -1,9 +1,8 @@ -import { decryptSecret } from "@homarr/common"; import { EVERY_5_SECONDS } from "@homarr/cron-jobs-core/expressions"; import { db } from "@homarr/db"; import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; import type { MediaRequestList, MediaRequestStats } from "@homarr/integrations"; -import { integrationCreatorByKind } from "@homarr/integrations"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; import { createItemAndIntegrationChannel } from "@homarr/redis"; import { createCronJob } from "../../lib"; @@ -14,23 +13,15 @@ export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS). }); for (const itemForIntegration of itemsForIntegration) { - for (const { integration, integrationId } of itemForIntegration.integrations) { - const integrationWithSecrets = { - ...integration, - decryptedSecrets: integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }; - - const requestsIntegration = integrationCreatorByKind(integration.kind, integrationWithSecrets); + for (const { integration } of itemForIntegration.integrations) { + const requestsIntegration = integrationCreatorFromSecrets(integration); const mediaRequests = await requestsIntegration.getRequestsAsync(); const requestsStats = await requestsIntegration.getStatsAsync(); const requestsUsers = await requestsIntegration.getUsersAsync(); const requestListChannel = createItemAndIntegrationChannel( "mediaRequests-requestList", - integrationId, + integration.id, ); await requestListChannel.publishAndUpdateLastStateAsync({ integration: { id: integration.id }, @@ -39,7 +30,7 @@ export const mediaRequestsJob = createCronJob("mediaRequests", EVERY_5_SECONDS). const requestStatsChannel = createItemAndIntegrationChannel( "mediaRequests-requestStats", - integrationId, + integration.id, ); await requestStatsChannel.publishAndUpdateLastStateAsync({ integration: { kind: integration.kind, name: integration.name }, diff --git a/packages/cron-jobs/src/jobs/integrations/media-server.ts b/packages/cron-jobs/src/jobs/integrations/media-server.ts index 9adb806b8..88c7e85ba 100644 --- a/packages/cron-jobs/src/jobs/integrations/media-server.ts +++ b/packages/cron-jobs/src/jobs/integrations/media-server.ts @@ -1,44 +1,21 @@ -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 { JellyfinIntegration } from "@homarr/integrations"; +import { db } from "@homarr/db"; +import { getItemsWithIntegrationsAsync } from "@homarr/db/queries"; +import { integrationCreatorFromSecrets } from "@homarr/integrations"; import { createItemAndIntegrationChannel } from "@homarr/redis"; import { createCronJob } from "../../lib"; export const mediaServerJob = createCronJob("mediaServer", EVERY_5_SECONDS).withCallback(async () => { - const itemsForIntegration = await db.query.items.findMany({ - where: eq(items.kind, "mediaServer"), - with: { - integrations: { - with: { - integration: { - with: { - secrets: { - columns: { - kind: true, - value: true, - }, - }, - }, - }, - }, - }, - }, + const itemsForIntegration = await getItemsWithIntegrationsAsync(db, { + kinds: ["mediaServer"], }); for (const itemForIntegration of itemsForIntegration) { - for (const integration of itemForIntegration.integrations) { - const jellyfinIntegration = new JellyfinIntegration({ - ...integration.integration, - decryptedSecrets: integration.integration.secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }); - const streamSessions = await jellyfinIntegration.getCurrentSessionsAsync(); - const channel = createItemAndIntegrationChannel("mediaServer", integration.integrationId); + for (const { integration } of itemForIntegration.integrations) { + const integrationInstance = integrationCreatorFromSecrets(integration); + const streamSessions = await integrationInstance.getCurrentSessionsAsync(); + const channel = createItemAndIntegrationChannel("mediaServer", integration.id); await channel.publishAndUpdateLastStateAsync(streamSessions); } } diff --git a/packages/cron-jobs/src/jobs/rss-feeds.ts b/packages/cron-jobs/src/jobs/rss-feeds.ts index 510eeef38..5c468520d 100644 --- a/packages/cron-jobs/src/jobs/rss-feeds.ts +++ b/packages/cron-jobs/src/jobs/rss-feeds.ts @@ -2,6 +2,7 @@ import type { FeedData, FeedEntry } from "@extractus/feed-extractor"; import { extract } from "@extractus/feed-extractor"; import SuperJSON from "superjson"; +import type { Modify } from "@homarr/common/types"; import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions"; import { db, eq } from "@homarr/db"; import { items } from "@homarr/db/schema/sqlite"; @@ -125,9 +126,12 @@ interface ExtendedFeedEntry extends FeedEntry { * We extend the feed with custom properties. * This interface omits the default entries with our custom definition. */ -interface ExtendedFeedData extends Omit { - entries?: ExtendedFeedEntry; -} +type ExtendedFeedData = Modify< + FeedData, + { + entries?: ExtendedFeedEntry; + } +>; export interface RssFeed { feedUrl: string; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index fbe355d6f..94736e857 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,4 +1,5 @@ import { objectKeys } from "@homarr/common"; +import type { AtLeastOneOf } from "@homarr/common/types"; export const integrationSecretKindObject = { apiKey: { isPublic: false }, @@ -8,36 +9,43 @@ export const integrationSecretKindObject = { export const integrationSecretKinds = objectKeys(integrationSecretKindObject); +interface integrationDefinition { + name: string; + iconUrl: string; + secretKinds: AtLeastOneOf; // at least one secret kind set is required + category: AtLeastOneOf; +} + export const integrationDefs = { sabNzbd: { name: "SABnzbd", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png", - category: ["useNetClient"], + category: ["downloadClient", "usenet"], }, nzbGet: { name: "NZBGet", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png", - category: ["useNetClient"], + category: ["downloadClient", "usenet"], }, deluge: { name: "Deluge", secretKinds: [["password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png", - category: ["downloadClient"], + category: ["downloadClient", "torrent"], }, transmission: { name: "Transmission", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png", - category: ["downloadClient"], + category: ["downloadClient", "torrent"], }, qBittorrent: { name: "qBittorrent", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png", - category: ["downloadClient"], + category: ["downloadClient", "torrent"], }, sonarr: { name: "Sonarr", @@ -111,15 +119,9 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png", category: ["smartHomeServer"], }, -} satisfies Record< - string, - { - name: string; - iconUrl: string; - secretKinds: [IntegrationSecretKind[], ...IntegrationSecretKind[][]]; // at least one secret kind set is required - category: IntegrationCategory[]; - } ->; +} as const satisfies Record; + +export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl; @@ -128,14 +130,34 @@ export const getIntegrationName = (integration: IntegrationKind) => integrationD export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => integrationDefs[integration].secretKinds[0]; -export const getAllSecretKindOptions = ( - integration: IntegrationKind, -): [IntegrationSecretKind[], ...IntegrationSecretKind[][]] => integrationDefs[integration].secretKinds; +export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => + integrationDefs[integration].secretKinds; -export const integrationKinds = objectKeys(integrationDefs); +/** + * Get all integration kinds that share a category, typed only by the kinds belonging to the category + * @param category Category to filter by, belonging to IntegrationCategory + * @returns Partial list of integration kinds + */ +export const getIntegrationKindsByCategory = (category: TCategory) => { + return objectKeys(integrationDefs).filter((integration) => + integrationDefs[integration].category.some((defCategory) => defCategory === category), + ) as AtLeastOneOf>; +}; -export type IntegrationSecretKind = (typeof integrationSecretKinds)[number]; -export type IntegrationKind = (typeof integrationKinds)[number]; +/** + * Directly get the types of the list returned by getIntegrationKindsByCategory + */ +export type IntegrationKindByCategory = { + [Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number] + ? Key + : never; +}[keyof typeof integrationDefs] extends infer U + ? //Needed to simplify the type when using it + U + : never; + +export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; +export type IntegrationKind = keyof typeof integrationDefs; export type IntegrationCategory = | "dnsHole" | "mediaService" @@ -143,6 +165,7 @@ export type IntegrationCategory = | "mediaSearch" | "mediaRequest" | "downloadClient" - | "useNetClient" + | "usenet" + | "torrent" | "smartHomeServer" | "indexerManager"; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 9fcbaacb7..fba9af698 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -11,6 +11,7 @@ export const widgetKinds = [ "smartHome-executeAutomation", "mediaServer", "calendar", + "downloads", "mediaRequests-requestList", "mediaRequests-requestStats", "rssFeed", diff --git a/packages/integrations/package.json b/packages/integrations/package.json index f239f4292..206b71a33 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -24,12 +24,17 @@ }, "prettier": "@homarr/prettier-config", "dependencies": { + "@ctrl/deluge": "^6.1.0", + "@ctrl/qbittorrent": "^9.0.1", + "@ctrl/transmission": "^6.1.0", "@homarr/common": "workspace:^0.1.0", + "@homarr/db": "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" + "@jellyfin/sdk": "^0.10.0", + "typed-rpc": "^5.1.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 4034a187b..2a1c46969 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,6 +1,14 @@ -import type { IntegrationKind } from "@homarr/definitions"; +import { decryptSecret } from "@homarr/common/server"; +import type { Modify } from "@homarr/common/types"; +import type { Integration as DbIntegration } from "@homarr/db/schema/sqlite"; +import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; +import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; +import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; +import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; +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 { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; @@ -11,15 +19,30 @@ import { PiHoleIntegration } from "../pi-hole/pi-hole-integration"; import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; import type { Integration, IntegrationInput } from "./integration"; -export const integrationCreatorByKind = ( - kind: TKind, - integration: IntegrationInput, +export const integrationCreator = ( + integration: IntegrationInput & { kind: TKind }, ) => { - if (!(kind in integrationCreators)) { - throw new Error(`Unknown integration kind ${kind}. Did you forget to add it to the integration creator?`); + if (!(integration.kind in integrationCreators)) { + throw new Error( + `Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`, + ); } - return new integrationCreators[kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>; + return new integrationCreators[integration.kind](integration) as InstanceType<(typeof integrationCreators)[TKind]>; +}; + +export const integrationCreatorFromSecrets = ( + integration: Modify & { + secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[]; + }, +) => { + return integrationCreator({ + ...integration, + decryptedSecrets: integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); }; export const integrationCreators = { @@ -29,6 +52,11 @@ export const integrationCreators = { jellyfin: JellyfinIntegration, sonarr: SonarrIntegration, radarr: RadarrIntegration, + sabNzbd: SabnzbdIntegration, + nzbGet: NzbGetIntegration, + qBittorrent: QBitTorrentIntegration, + deluge: DelugeIntegration, + transmission: TransmissionIntegration, jellyseerr: JellyseerrIntegration, overseerr: OverseerrIntegration, prowlarr: ProwlarrIntegration, 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..ae7d18b36 --- /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 { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { 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 getClientJobsAndStatusAsync(): 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 }) => DelugeIntegration.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 = DelugeIntegration.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 client.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 client.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 static 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..824a47a9c --- /dev/null +++ b/packages/integrations/src/download-client/nzbget/nzbget-integration.ts @@ -0,0 +1,123 @@ +import dayjs from "dayjs"; +import { rpcClient } from "typed-rpc"; + +import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; +import type { NzbGetClient } from "./nzbget-types"; + +export class NzbGetIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + const client = this.getClient(); + await client.version(); + } + + public async getClientJobsAndStatusAsync(): 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 = NzbGetIntegration.getUsenetQueueState(file.Status); + const time = + (file.RemainingSizeLo + file.RemainingSizeHi * Math.pow(2, 32)) / (nzbGetStatus.DownloadRate / 1000); + 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: Number.isFinite(time) ? time : 0, + added: (dayjs().unix() - file.DownloadTimeSec) * 1000, + state, + progress: file.DownloadedSizeMB / file.FileSizeMB, + category: file.Category, + }; + }) + .concat( + history.map((file, index): DownloadClientItem => { + const state = NzbGetIntegration.getUsenetHistoryState(file.ScriptStatus); + return { + type, + id: file.NZBID.toString(), + index, + name: file.Name, + size: file.FileSizeLo + file.FileSizeHi * Math.pow(2, 32), + time: (dayjs().unix() - 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(0, 0, 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.pathname += `${this.getSecretValue("username")}:${this.getSecretValue("password")}`; + url.pathname += url.pathname.endsWith("/") ? "jsonrpc" : "/jsonrpc"; + return rpcClient(url.toString()); + } + + private static getUsenetQueueState(status: string): DownloadClientItem["state"] { + switch (status) { + case "QUEUED": + return "queued"; + case "PAUSED": + return "paused"; + default: + return "downloading"; + } + } + + private static getUsenetHistoryState(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/nzbget/nzbget-types.ts b/packages/integrations/src/download-client/nzbget/nzbget-types.ts new file mode 100644 index 000000000..436db45a7 --- /dev/null +++ b/packages/integrations/src/download-client/nzbget/nzbget-types.ts @@ -0,0 +1,42 @@ +export interface NzbGetClient { + version: () => string; + status: () => NzbGetStatus; + listgroups: () => NzbGetGroup[]; + history: () => NzbGetHistory[]; + pausedownload: () => void; + resumedownload: () => void; + editqueue: (Command: string, Param: string, IDs: number[]) => void; + listfiles: (IDFrom: number, IDTo: number, NZBID: number) => { ID: number }[]; +} + +interface NzbGetStatus { + DownloadPaused: boolean; + DownloadRate: number; +} + +interface NzbGetGroup { + Status: string; + NZBID: number; + MaxPriority: number; + NZBName: string; + FileSizeLo: number; + FileSizeHi: number; + ActiveDownloads: number; + RemainingSizeLo: number; + RemainingSizeHi: number; + DownloadTimeSec: number; + Category: string; + DownloadedSizeMB: number; + FileSizeMB: number; +} + +interface NzbGetHistory { + ScriptStatus: string; + NZBID: number; + Name: string; + FileSizeLo: number; + FileSizeHi: number; + HistoryTime: number; + DownloadTimeSec: number; + Category: string; +} 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..407790fbf --- /dev/null +++ b/packages/integrations/src/download-client/qbittorrent/qbittorrent-integration.ts @@ -0,0 +1,112 @@ +import { QBittorrent } from "@ctrl/qbittorrent"; +import dayjs from "dayjs"; + +import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { 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 getClientJobsAndStatusAsync(): 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 }) => QBitTorrentIntegration.getTorrentState(state) !== "paused") === undefined; + const status: DownloadClientStatus = { paused, rates, type }; + const items = torrents.map((torrent): DownloadClientItem => { + const state = QBitTorrentIntegration.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 static 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..22b65d1b3 --- /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 { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { 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 getClientJobsAndStatusAsync(): 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 = SabnzbdIntegration.getUsenetQueueState(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 = SabnzbdIntegration.getUsenetHistoryState(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 await fetch(url) + .then((response) => { + if (!response.ok) { + throw new Error(response.statusText); + } + return response.json() as Promise; + }) + .catch((error) => { + if (error instanceof Error) { + throw new Error(error.message); + } else { + throw new Error("Error communicating with SABnzbd"); + } + }); + } + + private static getUsenetQueueState(status: string): DownloadClientItem["state"] { + switch (status) { + case "Queued": + return "queued"; + case "Paused": + return "paused"; + default: + return "downloading"; + } + } + + private static getUsenetHistoryState(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..9ea91c16c --- /dev/null +++ b/packages/integrations/src/download-client/sabnzbd/sabnzbd-schema.ts @@ -0,0 +1,121 @@ +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 + .number() + .or(z.string()) + .transform((priority) => (typeof priority === "number" ? priority : parseInt(priority))), + 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..2258546b8 --- /dev/null +++ b/packages/integrations/src/download-client/transmission/transmission-integration.ts @@ -0,0 +1,99 @@ +import { Transmission } from "@ctrl/transmission"; +import dayjs from "dayjs"; + +import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/download-client-data"; +import { DownloadClientIntegration } from "../../interfaces/downloads/download-client-integration"; +import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items"; +import type { DownloadClientStatus } from "../../interfaces/downloads/download-client-status"; + +export class TransmissionIntegration extends DownloadClientIntegration { + public async testConnectionAsync(): Promise { + await this.getClient().getSession(); + } + + public async getClientJobsAndStatusAsync(): 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 }) => TransmissionIntegration.getTorrentState(status) !== "paused") === undefined; + const status: DownloadClientStatus = { paused, rates, type }; + const items = torrents.map((torrent): DownloadClientItem => { + const state = TransmissionIntegration.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 static 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 a79bd5c1f..0b5cd8a35 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -2,18 +2,31 @@ export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; +export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration"; export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-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"; export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; // Types +export type { IntegrationInput } from "./base/integration"; +export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; +export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; +export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request"; export type { StreamSession } from "./interfaces/media-server/session"; +// Schemas +export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; + // Helpers -export { integrationCreatorByKind } from "./base/creator"; +export { integrationCreator, integrationCreatorFromSecrets } from "./base/creator"; 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..f82840dfe --- /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 DownloadClientJobsAndStatus { + 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..b14b4b6e6 --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-integration.ts @@ -0,0 +1,18 @@ +import { Integration } from "../../base/integration"; +import type { DownloadClientJobsAndStatus } 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 getClientJobsAndStatusAsync(): 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..aaca5b40a --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-items.ts @@ -0,0 +1,56 @@ +import type { Integration } from "@homarr/db/schema/sqlite"; +import { z } from "@homarr/validation"; + +const usenetQueueState = ["downloading", "queued", "paused"] as const; +const usenetHistoryState = ["completed", "failed", "processing"] as const; +const torrentState = ["leeching", "stalled", "paused", "seeding"] as const; + +/** + * DownloadClientItem + * Description: + * Normalized interface for downloading clients for Usenet and + * Torrents alike, using common properties and few extra optionals + * from each. + */ +export const downloadClientItemSchema = z.object({ + /** Unique Identifier provided by client */ + id: z.string(), + /** Position in queue */ + index: z.number(), + /** Filename */ + name: z.string(), + /** Torrent/Usenet identifier */ + type: z.enum(["torrent", "usenet"]), + /** Item size in Bytes */ + size: z.number(), + /** Total uploaded in Bytes, only required for Torrent items */ + sent: z.number().optional(), + /** Download speed in Bytes/s, only required if not complete + * (Says 0 only if it should be downloading but isn't) */ + downSpeed: z.number().optional(), + /** Upload speed in Bytes/s, only required for Torrent items */ + upSpeed: z.number().optional(), + /** Positive = eta (until completion, 0 meaning infinite), Negative = time since completion, in milliseconds*/ + time: z.number(), + /** Unix timestamp in milliseconds when the item was added to the client */ + added: z.number().optional(), + /** Status message, mostly as information to display and not for logic */ + state: z.enum(["unknown", ...usenetQueueState, ...usenetHistoryState, ...torrentState]), + /** Progress expressed between 0 and 1, can infer completion from progress === 1 */ + progress: z.number().min(0).max(1), + /** Category given to the item */ + category: z.string().or(z.array(z.string())).optional(), +}); + +export type DownloadClientItem = z.infer; + +export type ExtendedDownloadClientItem = { + integration: Integration; + received: number; + ratio?: number; + actions?: { + resume: () => void; + pause: () => void; + delete: ({ fromDisk }: { fromDisk: boolean }) => void; + }; +} & DownloadClientItem; 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..7c55e77c5 --- /dev/null +++ b/packages/integrations/src/interfaces/downloads/download-client-status.ts @@ -0,0 +1,23 @@ +import type { Integration } from "@homarr/db/schema/sqlite"; + +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 { + integration: Integration; + interact: boolean; + status?: { + /** To derive from current items */ + totalDown?: number; + /** To derive from current items */ + totalUp?: number; + ratio?: number; + } & DownloadClientStatus; +} diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts index 1c1696581..d4f796b82 100644 --- a/packages/integrations/test/home-assistant.spec.ts +++ b/packages/integrations/test/home-assistant.spec.ts @@ -1,3 +1,4 @@ +import { join } from "path"; import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; import { beforeAll, describe, expect, test } from "vitest"; @@ -57,7 +58,7 @@ const createHomeAssistantContainer = () => { return new GenericContainer(IMAGE_NAME) .withCopyFilesToContainer([ { - source: __dirname + "/volumes/home-assistant-config.zip", + source: join(__dirname, "/volumes/home-assistant-config.zip"), target: "/tmp/config.zip", }, ]) diff --git a/packages/integrations/test/nzbget.spec.ts b/packages/integrations/test/nzbget.spec.ts new file mode 100644 index 000000000..a23f081c7 --- /dev/null +++ b/packages/integrations/test/nzbget.spec.ts @@ -0,0 +1,195 @@ +import { join } from "path"; +import type { StartedTestContainer } from "testcontainers"; +import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { NzbGetIntegration } from "../src"; + +const username = "nzbget"; +const password = "tegbzn6789"; +const IMAGE_NAME = "linuxserver/nzbget:latest"; + +describe("Nzbget integration", () => { + beforeAll(async () => { + const containerRuntimeClient = await getContainerRuntimeClient(); + await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME)); + }, 100_000); + + test("Test connection should work", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + + // Act + const actAsync = async () => await nzbGetIntegration.testConnectionAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + + // Cleanup + await startedContainer.stop(); + }, 20_000); + + test("Test connection should fail with wrong credentials", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, "wrong-user", "wrong-password"); + + // Act + const actAsync = async () => await nzbGetIntegration.testConnectionAsync(); + + // Assert + await expect(actAsync()).rejects.toThrow(); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("pauseQueueAsync should work", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + + // Acts + const actAsync = async () => await nzbGetIntegration.pauseQueueAsync(); + const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("resumeQueueAsync should work", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + await nzbGetIntegration.pauseQueueAsync(); + + // Acts + const actAsync = async () => await nzbGetIntegration.resumeQueueAsync(); + const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ + status: { paused: false }, + }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Items should be empty", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + + // Act + const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ + items: [], + }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("1 Items should exist after adding one", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + await nzbGetAddItemAsync(startedContainer, username, password, nzbGetIntegration); + + // Act + const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + expect((await getAsync()).items).toHaveLength(1); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Delete item should result in empty items", async () => { + // Arrange + const startedContainer = await createNzbGetContainer().start(); + const nzbGetIntegration = createNzbGetIntegration(startedContainer, username, password); + const item = await nzbGetAddItemAsync(startedContainer, username, password, nzbGetIntegration); + + // Act + const getAsync = async () => await nzbGetIntegration.getClientJobsAndStatusAsync(); + const actAsync = async () => await nzbGetIntegration.deleteItemAsync(item, false); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ items: [] }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds*/ +}); + +const createNzbGetContainer = () => { + return new GenericContainer(IMAGE_NAME) + .withExposedPorts(6789) + .withEnvironment({ PUID: "0", PGID: "0" }) + .withWaitStrategy(Wait.forLogMessage("[ls.io-init] done.")); +}; + +const createNzbGetIntegration = (container: StartedTestContainer, username: string, password: string) => { + return new NzbGetIntegration({ + id: "1", + decryptedSecrets: [ + { + kind: "username", + value: username, + }, + { + kind: "password", + value: password, + }, + ], + name: "NzbGet", + url: `http://${container.getHost()}:${container.getMappedPort(6789)}`, + }); +}; + +const nzbGetAddItemAsync = async ( + container: StartedTestContainer, + username: string, + password: string, + integration: NzbGetIntegration, +) => { + // Add nzb file in the watch folder + await container.copyFilesToContainer([ + { + source: join(__dirname, "/volumes/usenet/test_download_100MB.nzb"), + target: "/downloads/nzb/test_download_100MB.nzb", + }, + ]); + // Trigger scanning of the watch folder (Only available way to add an item except "append" which is too complex and unnecessary) + await fetch(`http://${container.getHost()}:${container.getMappedPort(6789)}/${username}:${password}/jsonrpc`, { + method: "POST", + body: JSON.stringify({ method: "scan" }), + }); + // Retries up to 10000 times to let NzbGet scan and process the nzb (1 retry should suffice tbh but NzbGet is slow) + for (let i = 0; i < 10000; i++) { + const { + items: [item], + } = await integration.getClientJobsAndStatusAsync(); + if (item) { + // Remove the added time because NzbGet doesn't return it properly in this specific case + const { added: _, ...itemRest } = item; + return itemRest; + } + } + // Throws if it can't find the item + throw new Error("No item found"); +}; diff --git a/packages/integrations/test/sabnzbd.spec.ts b/packages/integrations/test/sabnzbd.spec.ts new file mode 100644 index 000000000..b549a8f3d --- /dev/null +++ b/packages/integrations/test/sabnzbd.spec.ts @@ -0,0 +1,235 @@ +import { join } from "path"; +import { GenericContainer, getContainerRuntimeClient, ImageName, Wait } from "testcontainers"; +import type { StartedTestContainer } from "testcontainers"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { SabnzbdIntegration } from "../src"; +import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items"; + +const DEFAULT_API_KEY = "8r45mfes43s3iw7x3oecto6dl9ilxnf9"; +const IMAGE_NAME = "linuxserver/sabnzbd:latest"; + +describe("Sabnzbd integration", () => { + beforeAll(async () => { + const containerRuntimeClient = await getContainerRuntimeClient(); + await containerRuntimeClient.image.pull(ImageName.fromString(IMAGE_NAME)); + }, 100_000); + + test("Test connection should work", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + + // Act + const actAsync = async () => await sabnzbdIntegration.testConnectionAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Test connection should fail with wrong ApiKey", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, "wrong-api-key"); + + // Act + const actAsync = async () => await sabnzbdIntegration.testConnectionAsync(); + + // Assert + await expect(actAsync()).rejects.toThrow(); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("pauseQueueAsync should work", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + + // Acts + const actAsync = async () => await sabnzbdIntegration.pauseQueueAsync(); + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ status: { paused: true } }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("resumeQueueAsync should work", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + await sabnzbdIntegration.pauseQueueAsync(); + + // Acts + const actAsync = async () => await sabnzbdIntegration.resumeQueueAsync(); + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ + status: { paused: false }, + }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Items should be empty", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + + // Act + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ + items: [], + }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("1 Items should exist after adding one", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); + + // Act + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.not.toThrow(); + expect((await getAsync()).items).toHaveLength(1); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Pause item should work", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); + + // Act + const actAsync = async () => await sabnzbdIntegration.pauseItemAsync(item); + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] }); + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Resume item should work", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); + await sabnzbdIntegration.pauseItemAsync(item); + + // Act + const actAsync = async () => await sabnzbdIntegration.resumeItemAsync(item); + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "paused" }] }); + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ items: [{ ...item, state: "downloading" }] }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds + + test("Delete item should result in empty items", async () => { + // Arrange + const startedContainer = await createSabnzbdContainer().start(); + const sabnzbdIntegration = createSabnzbdIntegration(startedContainer, DEFAULT_API_KEY); + const item = await sabNzbdAddItemAsync(startedContainer, DEFAULT_API_KEY, sabnzbdIntegration); + + // Act - fromDisk already doesn't work for sabnzbd, so only test deletion itself. + const actAsync = async () => + await sabnzbdIntegration.deleteItemAsync({ ...item, progress: 0 } as DownloadClientItem, false); + const getAsync = async () => await sabnzbdIntegration.getClientJobsAndStatusAsync(); + + // Assert + await expect(actAsync()).resolves.not.toThrow(); + await expect(getAsync()).resolves.toMatchObject({ items: [] }); + + // Cleanup + await startedContainer.stop(); + }, 20_000); // Timeout of 20 seconds +}); + +const createSabnzbdContainer = () => { + return new GenericContainer(IMAGE_NAME) + .withCopyFilesToContainer([ + { + source: join(__dirname, "/volumes/usenet/sabnzbd.ini"), + target: "/config/sabnzbd.ini", + }, + ]) + .withExposedPorts(1212) + .withEnvironment({ PUID: "0", PGID: "0" }) + .withWaitStrategy(Wait.forHttp("/", 1212)); +}; + +const createSabnzbdIntegration = (container: StartedTestContainer, apiKey: string) => { + return new SabnzbdIntegration({ + id: "1", + decryptedSecrets: [ + { + kind: "apiKey", + value: apiKey, + }, + ], + name: "Sabnzbd", + url: `http://${container.getHost()}:${container.getMappedPort(1212)}`, + }); +}; + +const sabNzbdAddItemAsync = async ( + container: StartedTestContainer, + apiKey: string, + integration: SabnzbdIntegration, +) => { + // Add nzb file in the watch folder + await container.copyFilesToContainer([ + { + source: join(__dirname, "/volumes/usenet/test_download_100MB.nzb"), + target: "/nzb/test_download_100MB.nzb", + }, + ]); + // Adding file is faster than triggering scan of the watch folder + // (local add: 1.4-1.6s, scan trigger: 2.5-2.7s, auto scan: 2.9-3s) + await fetch( + `http://${container.getHost()}:${container.getMappedPort(1212)}/api` + + "?mode=addlocalfile" + + "&name=%2Fnzb%2Ftest_download_100MB.nzb" + + `&apikey=${apiKey}`, + ); + // Retries up to 5 times to let SabNzbd scan and process the nzb (1 retry should suffice tbh) + for (let i = 0; i < 5; i++) { + const { + items: [item], + } = await integration.getClientJobsAndStatusAsync(); + if (item) return item; + } + // Throws if it can't find the item + throw new Error("No item found"); +}; diff --git a/packages/integrations/test/volumes/usenet/sabnzbd.ini b/packages/integrations/test/volumes/usenet/sabnzbd.ini new file mode 100755 index 000000000..14b644488 --- /dev/null +++ b/packages/integrations/test/volumes/usenet/sabnzbd.ini @@ -0,0 +1,407 @@ +__version__ = 19 +__encoding__ = utf-8 +[misc] +pre_script = None +queue_complete = "" +queue_complete_pers = 0 +bandwidth_perc = 100 +refresh_rate = 1 +interface_settings = '{"dateFormat":"fromNow","extraQueueColumns":[],"extraHistoryColumns":[],"displayCompact":false,"displayFullWidth":false,"displayTabbed":false,"confirmDeleteQueue":true,"confirmDeleteHistory":true,"keyboardShortcuts":true}' +queue_limit = 20 +config_lock = 0 +sched_converted = 0 +notified_new_skin = 2 +direct_unpack_tested = 1 +check_new_rel = 0 +auto_browser = 0 +language = en +enable_https_verification = 1 +host = 127.0.0.1 +port = 1212 +https_port = 1212 +username = "" +password = "" +bandwidth_max = 1125M +cache_limit = 128G +web_dir = Glitter +web_color = Auto +https_cert = server.cert +https_key = server.key +https_chain = "" +enable_https = 0 +inet_exposure = 4 +local_ranges = , +api_key = 8r45mfes43s3iw7x3oecto6dl9ilxnf9 +nzb_key = nc6q489idfb4fmdjh0uuqlsn4fjawrub +permissions = "" +download_dir = /temp +download_free = "" +complete_dir = /downloads +complete_free = "" +fulldisk_autoresume = 0 +script_dir = "" +nzb_backup_dir = "" +admin_dir = /admin +dirscan_dir = /nzb +dirscan_speed = 1 +password_file = "" +log_dir = logs +max_art_tries = 3 +load_balancing = 2 +top_only = 0 +sfv_check = 1 +quick_check_ext_ignore = nfo, sfv, srr +script_can_fail = 0 +enable_recursive = 1 +flat_unpack = 0 +par_option = "" +pre_check = 0 +nice = "" +win_process_prio = 3 +ionice = "" +fail_hopeless_jobs = 1 +fast_fail = 1 +auto_disconnect = 1 +no_dupes = 0 +no_series_dupes = 0 +series_propercheck = 1 +pause_on_pwrar = 1 +ignore_samples = 0 +deobfuscate_final_filenames = 0 +auto_sort = "" +direct_unpack = 1 +direct_unpack_threads = 6 +propagation_delay = 0 +folder_rename = 1 +replace_spaces = 0 +replace_dots = 0 +safe_postproc = 1 +pause_on_post_processing = 0 +sanitize_safe = 0 +cleanup_list = , +unwanted_extensions = , +action_on_unwanted_extensions = 0 +new_nzb_on_failure = 0 +history_retention = "" +enable_meta = 1 +quota_size = "" +quota_day = "" +quota_resume = 0 +quota_period = m +rating_enable = 0 +rating_host = "" +rating_api_key = "" +rating_filter_enable = 0 +rating_filter_abort_audio = 0 +rating_filter_abort_video = 0 +rating_filter_abort_encrypted = 0 +rating_filter_abort_encrypted_confirm = 0 +rating_filter_abort_spam = 0 +rating_filter_abort_spam_confirm = 0 +rating_filter_abort_downvoted = 0 +rating_filter_abort_keywords = "" +rating_filter_pause_audio = 0 +rating_filter_pause_video = 0 +rating_filter_pause_encrypted = 0 +rating_filter_pause_encrypted_confirm = 0 +rating_filter_pause_spam = 0 +rating_filter_pause_spam_confirm = 0 +rating_filter_pause_downvoted = 0 +rating_filter_pause_keywords = "" +enable_tv_sorting = 0 +tv_sort_string = "" +tv_sort_countries = 1 +tv_categories = , +enable_movie_sorting = 0 +movie_sort_string = "" +movie_sort_extra = -cd%1 +movie_extra_folder = 0 +movie_categories = movies, +enable_date_sorting = 0 +date_sort_string = "" +date_categories = tv, +schedlines = , +rss_rate = 60 +ampm = 0 +replace_illegal = 1 +start_paused = 0 +enable_all_par = 0 +enable_par_cleanup = 1 +enable_unrar = 1 +enable_unzip = 1 +enable_7zip = 1 +enable_filejoin = 1 +enable_tsjoin = 1 +overwrite_files = 0 +ignore_unrar_dates = 0 +backup_for_duplicates = 1 +empty_postproc = 0 +wait_for_dfolder = 0 +rss_filenames = 0 +api_logging = 1 +html_login = 1 +osx_menu = 1 +osx_speed = 1 +warn_dupl_jobs = 1 +helpfull_warnings = 1 +keep_awake = 1 +win_menu = 1 +allow_incomplete_nzb = 0 +enable_broadcast = 1 +max_art_opt = 0 +ipv6_hosting = 0 +fixed_ports = 1 +api_warnings = 1 +disable_api_key = 0 +no_penalties = 0 +x_frame_options = 1 +require_modern_tls = 0 +num_decoders = 3 +rss_odd_titles = nzbindex.nl/, nzbindex.com/, nzbclub.com/ +req_completion_rate = 100.2 +selftest_host = self-test.sabnzbd.org +movie_rename_limit = 100M +size_limit = 0 +show_sysload = 2 +history_limit = 10 +wait_ext_drive = 5 +max_foldername_length = 246 +nomedia_marker = "" +ipv6_servers = 1 +url_base = /sabnzbd +host_whitelist = , +max_url_retries = 10 +downloader_sleep_time = 10 +ssdp_broadcast_interval = 15 +email_server = "" +email_to = , +email_from = "" +email_account = "" +email_pwd = "" +email_endjob = 0 +email_full = 0 +email_dir = "" +email_rss = 0 +email_cats = *, +unwanted_extensions_mode = 0 +preserve_paused_state = 0 +process_unpacked_par2 = 1 +helpful_warnings = 1 +allow_old_ssl_tls = 0 +episode_rename_limit = 20M +socks5_proxy_url = "" +num_simd_decoders = 2 +ext_rename_ignore = , +sorters_converted = 1 +backup_dir = "" +replace_underscores = 0 +tray_icon = 1 +enable_season_sorting = 1 +receive_threads = 6 +switchinterval = 0.005 +enable_multipar = 1 +verify_xff_header = 0 +end_queue_script = None +no_smart_dupes = 0 +dupes_propercheck = 1 +history_retention_option = all +history_retention_number = 1 +ipv6_staging = 0 +[logging] +log_level = 1 +max_log_size = 5242880 +log_backups = 5 +[ncenter] +ncenter_enable = 0 +ncenter_cats = *, +ncenter_prio_startup = 1 +ncenter_prio_download = 0 +ncenter_prio_pause_resume = 0 +ncenter_prio_pp = 0 +ncenter_prio_complete = 1 +ncenter_prio_failed = 1 +ncenter_prio_disk_full = 1 +ncenter_prio_new_login = 0 +ncenter_prio_warning = 0 +ncenter_prio_error = 0 +ncenter_prio_queue_done = 1 +ncenter_prio_other = 1 +[acenter] +acenter_enable = 0 +acenter_cats = *, +acenter_prio_startup = 0 +acenter_prio_download = 0 +acenter_prio_pause_resume = 0 +acenter_prio_pp = 0 +acenter_prio_complete = 1 +acenter_prio_failed = 1 +acenter_prio_disk_full = 1 +acenter_prio_new_login = 0 +acenter_prio_warning = 0 +acenter_prio_error = 0 +acenter_prio_queue_done = 1 +acenter_prio_other = 1 +[ntfosd] +ntfosd_enable = 1 +ntfosd_cats = *, +ntfosd_prio_startup = 1 +ntfosd_prio_download = 0 +ntfosd_prio_pause_resume = 0 +ntfosd_prio_pp = 0 +ntfosd_prio_complete = 1 +ntfosd_prio_failed = 1 +ntfosd_prio_disk_full = 1 +ntfosd_prio_new_login = 0 +ntfosd_prio_warning = 0 +ntfosd_prio_error = 0 +ntfosd_prio_queue_done = 1 +ntfosd_prio_other = 1 +[prowl] +prowl_enable = 0 +prowl_cats = *, +prowl_apikey = "" +prowl_prio_startup = -3 +prowl_prio_download = -3 +prowl_prio_pause_resume = -3 +prowl_prio_pp = -3 +prowl_prio_complete = 0 +prowl_prio_failed = 1 +prowl_prio_disk_full = 1 +prowl_prio_new_login = -3 +prowl_prio_warning = -3 +prowl_prio_error = -3 +prowl_prio_queue_done = 0 +prowl_prio_other = 0 +[pushover] +pushover_token = "" +pushover_userkey = "" +pushover_device = "" +pushover_emergency_expire = 3600 +pushover_emergency_retry = 60 +pushover_enable = 0 +pushover_cats = *, +pushover_prio_startup = -3 +pushover_prio_download = -2 +pushover_prio_pause_resume = -2 +pushover_prio_pp = -3 +pushover_prio_complete = -1 +pushover_prio_failed = -1 +pushover_prio_disk_full = 1 +pushover_prio_new_login = -3 +pushover_prio_warning = 1 +pushover_prio_error = 1 +pushover_prio_queue_done = -1 +pushover_prio_other = -1 +[pushbullet] +pushbullet_enable = 0 +pushbullet_cats = *, +pushbullet_apikey = "" +pushbullet_device = "" +pushbullet_prio_startup = 0 +pushbullet_prio_download = 0 +pushbullet_prio_pause_resume = 0 +pushbullet_prio_pp = 0 +pushbullet_prio_complete = 1 +pushbullet_prio_failed = 1 +pushbullet_prio_disk_full = 1 +pushbullet_prio_new_login = 0 +pushbullet_prio_warning = 0 +pushbullet_prio_error = 0 +pushbullet_prio_queue_done = 0 +pushbullet_prio_other = 1 +[nscript] +nscript_enable = 0 +nscript_cats = *, +nscript_script = "" +nscript_parameters = "" +nscript_prio_startup = 1 +nscript_prio_download = 0 +nscript_prio_pause_resume = 0 +nscript_prio_pp = 0 +nscript_prio_complete = 1 +nscript_prio_failed = 1 +nscript_prio_disk_full = 1 +nscript_prio_new_login = 0 +nscript_prio_warning = 0 +nscript_prio_error = 0 +nscript_prio_queue_done = 1 +nscript_prio_other = 1 +[servers] +[categories] +[[audio]] +name = audio +order = 0 +pp = "" +script = Default +dir = "" +newzbin = "" +priority = -100 +[[software]] +name = software +order = 0 +pp = "" +script = Default +dir = "" +newzbin = "" +priority = -100 +[[books]] +name = books +order = 1 +pp = "" +script = Default +dir = books +newzbin = "" +priority = -100 +[[tv]] +name = tv +order = 0 +pp = "" +script = Default +dir = tvshows +newzbin = "" +priority = -100 +[[movies]] +name = movies +order = 0 +pp = "" +script = Default +dir = movies +newzbin = "" +priority = -100 +[[*]] +name = * +order = 0 +pp = 3 +script = Default +dir = "" +newzbin = "" +priority = 0 +[rss] +[apprise] +apprise_enable = 0 +apprise_cats = *, +apprise_urls = "" +apprise_target_startup = "" +apprise_target_startup_enable = 0 +apprise_target_download = "" +apprise_target_download_enable = 0 +apprise_target_pause_resume = "" +apprise_target_pause_resume_enable = 0 +apprise_target_pp = "" +apprise_target_pp_enable = 0 +apprise_target_complete = "" +apprise_target_complete_enable = 1 +apprise_target_failed = "" +apprise_target_failed_enable = 1 +apprise_target_disk_full = "" +apprise_target_disk_full_enable = 0 +apprise_target_new_login = "" +apprise_target_new_login_enable = 1 +apprise_target_warning = "" +apprise_target_warning_enable = 0 +apprise_target_error = "" +apprise_target_error_enable = 0 +apprise_target_queue_done = "" +apprise_target_queue_done_enable = 0 +apprise_target_other = "" +apprise_target_other_enable = 1 diff --git a/packages/integrations/test/volumes/usenet/test_download_100MB.nzb b/packages/integrations/test/volumes/usenet/test_download_100MB.nzb new file mode 100644 index 000000000..e97940220 --- /dev/null +++ b/packages/integrations/test/volumes/usenet/test_download_100MB.nzb @@ -0,0 +1,317 @@ + + + + + + alt.binaries.test + + + EkZuHcMrVxSbBbBaSuFgVbMm-1658672638813@nyuu + HeTrDnAcEoFtWyJhTdPkNhPe-1658672638816@nyuu + OyFbRuRmVxPzXrZoKpWhJsKm-1658672638821@nyuu + NwNaDvOgStUoRfVbXmZxKeQf-1658672638822@nyuu + DpZgLkTwXvIePlNdDiCcEkXu-1658672638853@nyuu + HlTnIiCoXaLbOyOpXyIsMjJo-1658672638855@nyuu + YqLsWoYuZnHbYvCjSuZpJdQx-1658672638856@nyuu + TgRzBeNrGuQhTxIdLbZgGnNv-1658672638857@nyuu + BtUmYfDwAaSdWgRnWjKfRkMl-1658672638862@nyuu + EoUoUdSxYgIhVlQrPpMtHzFg-1658672638883@nyuu + MeEhBmZsBzSqEtZcFzLqUwMr-1658672639406@nyuu + VwBfZmSuHdVuUfJsUnCiKgAl-1658672639428@nyuu + IfJyWnIgPhKkFvEmSqQiQzSd-1658672639461@nyuu + LiRyAkTyOwRkVzMnJpAzJlQr-1658672639469@nyuu + PwMjYlAzKaMbOcWjGrNhLvNc-1658672639479@nyuu + + + + + alt.binaries.test + + + UtJrLhPwPvHrZjTvUjZrCpKw-1658672639558@nyuu + OzChWnChCwAeBsHrBvKvPcGr-1658672639883@nyuu + VsLoYaHzQfOmDgNdPnTzPzLx-1658672639939@nyuu + FkDiOcGkYxNwOgLrJaWcShKy-1658672640012@nyuu + OyGyQxWoHmMxHzPaRdGeMlXz-1658672640035@nyuu + NiFsKxNsWjCxXxQfPtNmGyOw-1658672640039@nyuu + JqMdEoVhTaRtAdLeYfAeCvRi-1658672640078@nyuu + NkOnBwJtHjBoMlUkHjHrNdGo-1658672640324@nyuu + QwQnXeMrZjKiJoCbPuSbMjPq-1658672640398@nyuu + ZiPvOsXwOjTqIjLnIrKbBdXv-1658672640437@nyuu + DwJyDjVzBhKxRyLxIxXqRfHp-1658672640521@nyuu + ArRbXcZqRaDhAgRsNmMsGeBh-1658672640545@nyuu + UvYkRiCmXfBoIrYxCdEjMtJw-1658672640563@nyuu + TqHaSdLuNlWvAaFqZlHqZzJr-1658672640762@nyuu + SdNiSzMzVdJkFtTtGtJyEcKi-1658672640859@nyuu + + + + + alt.binaries.test + + + ZpUwNcWlXaVzJeJcBjXmBqCu-1658672640910@nyuu + AtQtJrYjNnYdVbJsXuKvXkSc-1658672640969@nyuu + NzBlVfVlKtTxFaIfZxDgAfHa-1658672641009@nyuu + JmBgCqStEdWlXqCdWgMtRaKh-1658672641040@nyuu + XyLjFiRjRuWmHeVhHmIhIzTg-1658672641200@nyuu + IvKxDaYbZkGhMsJiZcXtMhFk-1658672641311@nyuu + YuGtYkBnWtRiLdMeUrRxIbId-1658672641377@nyuu + GcNoBzAgAhSjJbQkFyKuKbAj-1658672641454@nyuu + RlLtFgVnBfWgJpPwTtPoJjLf-1658672641491@nyuu + BxLxScLaVfDoNmDwRkMbUxPg-1658672641517@nyuu + LhVnUdRnVqFyUiThZyIrNfMt-1658672641649@nyuu + VoVyMbIvAuZnWhZoEqVuRpJd-1658672641769@nyuu + GnMqIqUjMgVlPsZgAuLlAtGs-1658672641851@nyuu + YzPbErKpMvWhVgMiNgJoRmNa-1658672641933@nyuu + FaSoKgLtJxPsOsCyPuWgNzEe-1658672641972@nyuu + + + + + alt.binaries.test + + + PoGtRtIyThRwHgJbSrJhXuNq-1658672642009@nyuu + CnRbGgFpMxKxKyKtMmZbSuSa-1658672642085@nyuu + NrSeRjXxKaBqWnZaDzMiAmBi-1658672642217@nyuu + WwMyBrUwInZkYjUfIsDkLkEr-1658672642300@nyuu + DxRtSoVyQrChKqSySdPoDvGn-1658672642384@nyuu + SaMlZuQzOiHtNpUcOzRqAkOw-1658672642443@nyuu + RuGaHaWfEsAeGpLwLzQpFdOd-1658672642476@nyuu + CmNcLvFdZfTjIlXcQiWdHuTe-1658672642524@nyuu + UxRnTlCuZjUcIcXhAmYkDdQz-1658672642677@nyuu + PvLmSwSzFzJjLoNiVdCpAqKp-1658672642735@nyuu + VjVmMvDxOzXvNmOrOwGdBbVg-1658672642826@nyuu + ToFnSxDePaBlQcEjViYzSdGo-1658672642903@nyuu + LjHkMlYqRaQcBpVkFjCuXrHc-1658672642938@nyuu + DpAmYrOyHoXkGkCfZcDiBiIn-1658672642972@nyuu + DkGlMfAxEnPcPlAjIbBpNpFg-1658672643110@nyuu + + + + + alt.binaries.test + + + LkSeRbWaNmKoPaLxRpYcOjWf-1658672643185@nyuu + MoUlMsBmZmWtBkFzJtRuYcJy-1658672643272@nyuu + QmYmNyEiSmVgSqVaQmEvXuBh-1658672643358@nyuu + EfHzOhLcXqKfCwRiKcAmYdCf-1658672643403@nyuu + YpBuJtUmPlDvLiPpKlPoTcZg-1658672643425@nyuu + PmGyAhWxVtBiOcOhGaIwSxRd-1658672643555@nyuu + HaSrHrMhKqHxDdDcYqNkUdEp-1658672643632@nyuu + QcDnQmPkMiHuWqIrHsRqWaYu-1658672643725@nyuu + SjThZtNpPqLlReTpKlQpTwBk-1658672643799@nyuu + KjMnVtRmOgJjYnYeQuLdRkNd-1658672643832@nyuu + UeIdYsQtLaKkDqJpFjTrAjSp-1658672643871@nyuu + LbVrYoMpQzDbNzXbAdNjEvAo-1658672643996@nyuu + QdUwTpUvVhBtDaGvYsTrIuAt-1658672644078@nyuu + FtFgNeYsBoLzImYqDbGfSdQf-1658672644160@nyuu + OsQiRhKjTyYwAwUaGmUpUlLe-1658672644258@nyuu + + + + + alt.binaries.test + + + HwJpGfDtJuCiCnLgCuTxAgOe-1658672644335@nyuu + DxAdNjPhGcJhNzQeYlJjYaAj-1658672644340@nyuu + UsXkQuGsJuQgSpZzAqYqDhPm-1658672644365@nyuu + OvDlAlUaWpZuIvImBcInGxZm-1658672644530@nyuu + JrGfSbDkHsIrIiZzLpWuJzZj-1658672644599@nyuu + TfYyCsKwKgUfYvXlZwGdLuJx-1658672644713@nyuu + MgQiXjLvFpEqNdIoMxEkPoJb-1658672644783@nyuu + TbBhHaXgToWiTjBkTvPfVjSf-1658672644805@nyuu + NpZaHwXwIrZdCeQfBfJuZhVm-1658672644831@nyuu + EvDjIqRhNmFzYsTqFxUfLmJo-1658672644945@nyuu + YcAnJpKgSmDmTrExKtClJiJw-1658672645044@nyuu + UdPfUkYtQqEfBiYsHeJnBoFv-1658672645173@nyuu + GjXmLsWnXvQuOhZrXuFaQsWt-1658672645264@nyuu + YoEsZaVnNrElAnIoBdZkEsUl-1658672645310@nyuu + FiDqTcLyQjSiMoKwNkJcOpKu-1658672645321@nyuu + + + + + alt.binaries.test + + + BcCnTfTbAqSiUoOkJyMhOpGx-1658672645395@nyuu + QsClUcDuHqEcDiTpOdGfDgFs-1658672645483@nyuu + EsXdXwNbZnIsFhPxQkKhSaHj-1658672645616@nyuu + XuDkCuXkIkJsIjAdKsTkLqWq-1658672645751@nyuu + PfEjJjTjHiCsXqDxYiBcUxCw-1658672645777@nyuu + IrGgIuRlScWcZwCtDjTrQdKy-1658672645805@nyuu + LwVpWrWgUjSuHyLqXoZrMaKb-1658672645859@nyuu + VxLjOjYzKeEoGxUgBeIwSfFb-1658672645927@nyuu + OaQySkXmXlTnWbSoLkOmExIn-1658672646084@nyuu + JmAsMqGcGaVsPiUcRbWqScYh-1658672646171@nyuu + EcOcFyCdIaJvOnNjYkMgTyLc-1658672646251@nyuu + JdTfZrWbKhYyPpCuOkEuUpWs-1658672646289@nyuu + CtZkDeFoUhJyXeTsOxQcPfEg-1658672646332@nyuu + ArAjLtAfYtVjRxHwPeTfSsPw-1658672646366@nyuu + CmKcCzHnYhEqUxMoOyPwNgUe-1658672646549@nyuu + + + + + alt.binaries.test + + + ExOtMnHzYeTeXbPrQpMqQcEd-1658672646626@nyuu + FvHhUgEeOuOmZuJrOeOdCmBp-1658672646690@nyuu + OgIaDbSgOmMjGkNgYzAvOzEa-1658672646751@nyuu + LxTeXnRpDpQsMjQxIpRzFfQo-1658672646793@nyuu + VcToYzYiJqAwGvCmMiVsGqNj-1658672646834@nyuu + XhBxFwNzQdHkIoGcHdMeDeSo-1658672646992@nyuu + BmQxAvJfEwEdKzVoMxCoVmIr-1658672647067@nyuu + PxYzIiRmDrSdFmKaSfTtQrWp-1658672647129@nyuu + PnSpIsPeHgAfThOjXyOpNlCo-1658672647192@nyuu + YfHjYuKnUvRlBnKdDqOoGnKo-1658672647224@nyuu + WpPkPhPeXsBiMkAkUcEcZuQg-1658672647285@nyuu + SlOeJlNtDjHqTuEkAeNxMdDk-1658672647439@nyuu + AdCgCfRmEvZkRzXgCoLrHfGa-1658672647508@nyuu + JrQhNuXmRiLjRaBvNlBzMgAd-1658672647568@nyuu + XiYcGuBtZdChJfIeKeYwAsHy-1658672647654@nyuu + + + + + alt.binaries.test + + + YnIxYaQuEcKvMpSyYnEeAvVn-1658672647688@nyuu + XrFdJwZqRcRtHnIcDnEzCoMc-1658672647743@nyuu + WfRuRmRrPwGgKwZhPaAmNpOn-1658672647970@nyuu + PkCrNtOrEvNvRoPaDuAuGqSf-1658672648003@nyuu + KsFvGlAdBjRfTlZhVxEuWxJm-1658672648029@nyuu + ZnIxXyHkDpGpBkLlPkQnHwWt-1658672648104@nyuu + RhVuUwDpCoRiUoNzUpOpFrWp-1658672648150@nyuu + JpYiPxYmAaClCuXtYwLcTkHb-1658672648199@nyuu + GiSuQeEyMkMeVaJmReDyKgVt-1658672648484@nyuu + LrFxMoDtJaNcEiLgDxQoFgIq-1658672648486@nyuu + BfUcDaUxLsNrZzAvBrLcRdWw-1658672648524@nyuu + MgHqJlXjFsEmGtCuCvTrMfDl-1658672648565@nyuu + DgZiJzKyBoGaWvXrRcRfPbZy-1658672648642@nyuu + SvAwVvXyWaFgPkAwAxLmAoVe-1658672648661@nyuu + UwZyZtBxBlZdQnIbMkAmFrUx-1658672648994@nyuu + + + + + alt.binaries.test + + + MzGbXzBiDkQkCkPjHgRfYgSt-1658672650132@nyuu + + + + + alt.binaries.test + + + QbGaVgPmAxUeYtGfRrNaCgPi-1658672649037@nyuu + TlYaEwZcLkMxXiVlWnXbBhCo-1658672649043@nyuu + JvFuObAaRgTpRdAaNsBnUjSf-1658672649046@nyuu + ViJqMxYcZuCzRiXqZqPyXhVl-1658672649105@nyuu + QaYyAkGsSmRwGwWlYwOcIdCh-1658672649138@nyuu + KdLcItOyTuDfZlFvDgFoGjLx-1658672649505@nyuu + DwEiPdQdSdKbYjQzSpCtNnBp-1658672649527@nyuu + JjZfPzJoYrSnSqOzLfQdLaJe-1658672649559@nyuu + JpAdAoOiWbLlElNnXyZqUrZk-1658672649591@nyuu + KcXsPhOqSmVlImNiAaBxOeDg-1658672649593@nyuu + OcKeQsZvSvCzZzGkSyYaIwLe-1658672649621@nyuu + TpSgWsQbCxSvRhCpNeVxXpQp-1658672650017@nyuu + FgLlWiOgZsXwZbDiUfRlUbAh-1658672650037@nyuu + DpWgTfSaHsEyDoRwKmMrHlNg-1658672650077@nyuu + ApWcZeAvJfEfArBnTqKaAlTi-1658672650131@nyuu + + + + + alt.binaries.test + + + GiCuNqThWxXhQtBdIpCuSpTu-1658672650163@nyuu + + + + + alt.binaries.test + + + XwSuKgDcYgYbThUtGbFnYlMr-1658672650478@nyuu + + + + + alt.binaries.test + + + XtIpShElZfGwAxEdWcWmMoSg-1658672650533@nyuu + + + + + alt.binaries.test + + + YjBcYcXxFoSmTdKrXbQaVcEc-1658672650555@nyuu + + + + + alt.binaries.test + + + YrBmEjHwHgWrGpWwWvHnZsNr-1658672650628@nyuu + + + + + alt.binaries.test + + + HmLkJbFaPwGnDeHoAsUeWvOx-1658672650641@nyuu + BgMgXvTfPmQpMeIxMrVbSbWb-1658672650648@nyuu + + + + + alt.binaries.test + + + InAlBjYtHfRoZbUcLbLjVwGg-1658672650925@nyuu + UeLxHaYaYrGcWoApMiIeUcFc-1658672650940@nyuu + TmTyRsQpWjLvJoZtYvDxKfDk-1658672650960@nyuu + + + + + alt.binaries.test + + + AoFjKyMxTlDpZtDzHyJtFaVt-1658672651017@nyuu + WbUyNnEyReLnSdNwBsVqVfVc-1658672651029@nyuu + FaXfJfTqWuIiMvTlGdOfGgKi-1658672651035@nyuu + NeEnAwTqVeRoJuStBvPhSsCf-1658672651324@nyuu + FeFfMtWjQyDkIcPaPnFnTvZl-1658672651372@nyuu + JxFgMzBwLqVoRcPuJzHoSgFy-1658672651406@nyuu + + + + + alt.binaries.test + + + ZrZzDkZqMlGxTlXsOxZzWkFy-1658672651436@nyuu + EkIfIsZtKbFcHyLtEiOvCgUe-1658672651500@nyuu + FdAlCsPqQgToRlEcZxCzHhFu-1658672651528@nyuu + OnYrJuAaClWaDjEdFmYoDaKt-1658672651727@nyuu + TsJbMqVtYcIaGqEvShTyEhWf-1658672651793@nyuu + UbNvVcQoDxAfCiPsEqFfGkDu-1658672651860@nyuu + + + diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts index 6ed52db7c..0ec256693 100644 --- a/packages/old-import/src/widgets/definitions/index.ts +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -52,6 +52,7 @@ export const widgetKindMapping = { app: null, // In oldmarr apps were not widgets clock: "date", calendar: "calendar", + downloads: "torrents-status", weather: "weather", rssFeed: "rss", video: "video-stream", diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index 6fef8c2a2..afc89393f 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -36,6 +36,18 @@ const optionMapping: OptionMapping = { timezone: (oldOptions) => oldOptions.timezone, useCustomTimezone: () => true, }, + downloads: { + activeTorrentThreshold: (oldOptions) => oldOptions.speedLimitOfActiveTorrents, + applyFilterToRatio: (oldOptions) => oldOptions.displayRatioWithFilter, + categoryFilter: (oldOptions) => oldOptions.labelFilter, + filterIsWhitelist: (oldOptions) => oldOptions.labelFilterIsWhitelist, + enableRowSorting: (oldOptions) => oldOptions.rowSorting, + showCompletedTorrent: (oldOptions) => oldOptions.displayCompletedTorrents, + columns: () => ["integration", "name", "progress", "time", "actions"], + defaultSort: () => "type", + descendingDefaultSort: () => false, + showCompletedUsenet: () => true, + }, weather: { forecastDayCount: (oldOptions) => oldOptions.forecastDays, hasForecast: (oldOptions) => oldOptions.displayWeekly, diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index c86bcdd5b..b22979d42 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1138,6 +1138,99 @@ 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", + }, + enableRowSorting: { + label: "Enable items sorting", + }, + defaultSort: { + label: "Column used for sorting by default", + }, + descendingDefaultSort: { + label: "Invert sorting", + }, + 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", + }, + }, + errors: { + noColumns: "Select Columns in Items", + noCommunications: "Can't load data from integration", + }, + 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", + }, "mediaRequests-requestList": { name: "Media Requests List", description: "See a list of all media requests from your Overseerr or Jellyseerr instance", @@ -1746,6 +1839,9 @@ export default { mediaOrganizer: { label: "Media Organizers", }, + downloads: { + label: "Downloads", + }, mediaRequests: { label: "Media Requests", }, diff --git a/packages/widgets/package.json b/packages/widgets/package.json index f50042230..fa7026db6 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -25,7 +25,9 @@ "dependencies": { "@extractus/feed-extractor": "^7.1.3", "@homarr/api": "workspace:^0.1.0", + "@homarr/auth": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0", + "@homarr/db": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0", "@homarr/form": "workspace:^0.1.0", "@homarr/integrations": "workspace:^0.1.0", 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/calendar/index.ts b/packages/widgets/src/calendar/index.ts index 2adf42b13..ff7d8d8a5 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -1,5 +1,6 @@ import { IconCalendar } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { z } from "@homarr/validation"; import { createWidgetDefinition } from "../definition"; @@ -17,7 +18,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef defaultValue: 2, }), })), - supportedIntegrations: ["sonarr", "radarr", "lidarr", "readarr"], + supportedIntegrations: getIntegrationKindsByCategory("calendar"), }) .withServerData(() => import("./serverData")) .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 0ee0e36b9..9bb36b275 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -93,6 +93,11 @@ export type WidgetComponentProps = WidgetProps } & { boardId: string | undefined; // undefined when in preview mode isEditMode: boolean; + setOptions: ({ + newOptions, + }: { + newOptions: Partial>>; + }) => void; width: number; height: number; }; diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 8fb9027bb..da6e62659 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -1,5 +1,7 @@ import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -10,7 +12,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef defaultValue: true, }), })), - supportedIntegrations: ["piHole", "adGuardHome"], + supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index 9267f8462..bb4d280af 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -1,5 +1,7 @@ import { IconAd, IconServerOff } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -17,7 +19,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef defaultValue: "grid", }), })), - supportedIntegrations: ["piHole", "adGuardHome"], + supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx new file mode 100644 index 000000000..5fae5bce5 --- /dev/null +++ b/packages/widgets/src/downloads/component.tsx @@ -0,0 +1,922 @@ +"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, useTimeout } from "@mantine/hooks"; +import type { IconProps } from "@tabler/icons-react"; +import { + IconAlertTriangle, + IconCirclesRelation, + IconInfinity, + IconInfoCircle, + IconPlayerPause, + IconPlayerPlay, + IconTrash, + IconX, +} 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 { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; +import { humanFileSize } from "@homarr/common"; +import type { Integration } from "@homarr/db/schema/sqlite"; +import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { + DownloadClientJobsAndStatus, + ExtendedClientStatus, + ExtendedDownloadClientItem, +} from "@homarr/integrations"; +import { useScopedI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationSelectedError } from "../errors"; + +//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, +}; + +const actionIconIconStyle: IconProps["style"] = { + height: "var(--ai-icon-size)", + width: "var(--ai-icon-size)", +}; + +const standardIconStyle: IconProps["style"] = { + height: "var(--icon-size)", + width: "var(--icon-size)", +}; + +const invalidateTime = 30000; + +export default function DownloadClientsWidget({ + isEditMode, + integrationIds, + options, + serverData, + setOptions, +}: WidgetComponentProps<"downloads">) { + const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => + integrationIds.includes(id) ? [id] : [], + ); + + const [currentItems, currentItemsHandlers] = useListState<{ + integration: Integration; + timestamp: Date; + data: DownloadClientJobsAndStatus | null; + }>( + //Automatically invalidate data older than 30 seconds + serverData?.initialData?.map((item) => + dayjs().diff(item.timestamp) < invalidateTime ? item : { ...item, timestamp: new Date(0), data: null }, + ) ?? [], + ); + + //Invalidate all data after no update for 30 seconds using timer + const invalidationTimer = useTimeout( + () => { + currentItemsHandlers.applyWhere( + () => true, + (item) => ({ ...item, timestamp: new Date(0), data: null }), + ); + }, + invalidateTime, + { autoInvoke: true }, + ); + + //Translations + const t = useScopedI18n("widget.downloads"); + const tCommon = useScopedI18n("common"); + + //Item modal state and selection + const [clickedIndex, setClickedIndex] = useState(0); + const [opened, { open, close }] = useDisclosure(false); + + //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(); + + //Subscribe to dynamic data changes + clientApi.widget.downloads.subscribeToJobsAndStatuses.useSubscription( + { + integrationIds, + }, + { + onData: (data) => { + //Use cyclical update to invalidate data older than 30 seconds from unresponsive integrations + const invalidIndexes = currentItems + //Don't update already invalid data (new Date (0)) + .filter(({ timestamp }) => dayjs().diff(timestamp) > invalidateTime && timestamp > new Date(0)) + .map(({ integration }) => integration.id); + currentItemsHandlers.applyWhere( + ({ integration }) => invalidIndexes.includes(integration.id), + //Set date to now so it won't update that integration for at least 30 seconds + (item) => ({ ...item, timestamp: new Date(0), data: null }), + ); + //Find id to update + const updateIndex = currentItems.findIndex((pair) => pair.integration.id === data.integration.id); + if (updateIndex >= 0) { + //Update found index + currentItemsHandlers.setItem(updateIndex, data); + } else if (integrationIds.includes(data.integration.id)) { + //Append index not found (new integration) + currentItemsHandlers.append(data); + } + //Reset no update timer + invalidationTimer.clear(); + invalidationTimer.start(); + }, + }, + ); + + //Flatten Data array for which each element has it's integration, data (base + calculated) and actions. Memoized on data subscription + const data = useMemo( + () => + currentItems + //Insure it is only using selected integrations + .filter(({ integration }) => integrationIds.includes(integration.id)) + //Removing any integration with no data associated + .filter( + (pair): pair is { integration: Integration; timestamp: Date; data: DownloadClientJobsAndStatus } => + pair.data != null, + ) + //Construct normalized items list + .flatMap((pair) => + //Apply user white/black list + pair.data.items + .filter( + ({ category }) => + options.filterIsWhitelist === + options.categoryFilter.some((filter) => + (Array.isArray(category) ? category : [category]).includes(filter), + ), + ) + //Filter completed items following widget option + .filter( + ({ type, progress, upSpeed }) => + (type === "torrent" && + ((progress === 1 && + options.showCompletedTorrent && + (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024) || + progress !== 1)) || + (type === "usenet" && ((progress === 1 && options.showCompletedUsenet) || progress !== 1)), + ) + //Add extrapolated data and actions if user is allowed interaction + .map((item): ExtendedDownloadClientItem => { + const received = Math.floor(item.size * item.progress); + const integrationIds = [pair.integration.id]; + 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, + //Only add if permission to use mutations + actions: integrationsWithInteractions.includes(pair.integration.id) + ? { + resume: () => mutateResumeItem({ integrationIds, item }), + pause: () => mutatePauseItem({ integrationIds, item }), + delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds, item, fromDisk }), + } + : undefined, + }; + }), + ) + //flatMap already sorts by integration by nature, add sorting by integration type (usenet | torrent) + .sort(({ type: typeA }, { type: typeB }) => typeA.length - typeB.length), + [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(({ integration, data }): ExtendedClientStatus => { + const interact = integrationsWithInteractions.includes(integration.id); + if (!data) return { integration, interact }; + const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); + /** Derived from current items */ + const { totalUp, totalDown } = data.items + .filter( + ({ category }) => + !options.applyFilterToRatio || + 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 ?? 0) + (sent ?? 0) : undefined, + totalDown: totalDown + size * progress, + }), + { totalDown: 0, totalUp: isTorrent ? 0 : undefined }, + ); + return { + integration, + interact, + status: { + totalUp, + totalDown, + ratio: totalUp === undefined ? undefined : totalUp / totalDown, + ...data.status, + }, + }; + }) + .sort( + ({ status: statusA }, { status: statusB }) => + (statusA?.type.length ?? Infinity) - (statusB?.type.length ?? Infinity), + ), + [currentItems, integrationIds, options], + ); + + //Check existing types between torrents and usenet + const integrationTypes: ExtendedDownloadClientItem["type"][] = []; + if (data.some(({ type }) => type === "torrent")) integrationTypes.push("torrent"); + if (data.some(({ type }) => type === "usenet")) integrationTypes.push("usenet"); + + //Set the visibility of columns depending on widget settings and available data/integrations. + const columnVisibility: MRT_VisibilityState = { + id: options.columns.includes("id"), + actions: options.columns.includes("actions") && integrationsWithInteractions.length > 0, + 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, + ); + + //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 that should apply or be applied to all elements + const baseStyle: MantineStyleProp = { + "--total-width": totalWidth, + "--ratio-width": "calc(100cqw / var(--total-width))", + "--space-size": "calc(var(--ratio-width) * 0.1)", //Standard gap and spacing value + "--text-fz": "calc(var(--ratio-width) * 0.45)", //General Font Size + "--button-fz": "var(--text-fz)", + "--icon-size": "calc(var(--ratio-width) * 2 / 3)", //Normal icon size + "--ai-icon-size": "calc(var(--ratio-width) * 0.5)", //Icon inside action icons size + "--button-size": "calc(var(--ratio-width) * 0.75)", //Action Icon, button and avatar size + "--image-size": "var(--button-size)", + "--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(--column-width)", + height: "var(--ratio-width)", + padding: "var(--space-size)", + transition: "unset", + "--key-width": columnsRatios[key], + "--column-width": "calc((var(--key-width)/var(--total-width) * 100cqw))", + }; + return { + id: key, + accessorKey: key, + header: key, + size: columnsRatios[key], + mantineTableBodyCellProps: { style, align }, + mantineTableHeadCellProps: { + style, + align: isEditMode ? "center" : 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 pausedAction = row.original.state === "paused" ? "resume" : "pause"; + const [opened, { open, close }] = useDisclosure(false); + + return actions ? ( + + + + {pausedAction === "resume" ? ( + + ) : ( + + )} + + + + + + + + + + + + + + + + ) : ( + + + + ); + }, + }, + { + ...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 && {humanFileSize(downSpeed, "/s")}; + }, + }, + { + ...columnsDefBase({ key: "id", showHeader: false, align: "center" }), + 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 && {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 && {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": "var(--button-size)", + "--dragButtonSize": "var(--button-size)", + }, + }, + mantineTableBodyProps: { style: editStyle }, + mantineTableBodyCellProps: ({ cell, row }) => ({ + onClick: () => { + setClickedIndex(row.index); + if (cell.column.id !== "actions") open(); + }, + }), + onColumnOrderChange: (order) => { + //Order has a tendency to add the disabled column at the end of the the real ordered array + const columnOrder = (order as typeof options.columns).filter((column) => options.columns.includes(column)); + setOptions({ newOptions: { columns: columnOrder } }); + }, + initialState: { + sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }], + 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"); + + //Used for Global Torrent Ratio + const globalTraffic = clients + .filter(({ integration: { kind } }) => + getIntegrationKindsByCategory("torrent").some((integrationKind) => integrationKind === kind), + ) + .reduce( + ({ up, down }, { status }) => ({ + up: up + (status?.totalUp ?? 0), + down: down + (status?.totalDown ?? 0), + }), + { up: 0, down: 0 }, + ); + + if (integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + + if (options.columns.length === 0) + return ( +
+ {t("errors.noColumns")} +
+ ); + + //The actual widget + return ( + + + + {integrationTypes.includes("torrent") && ( + + {tCommon("rtl", { value: t("globalRatio"), symbol: tCommon("symbols.colon") })} + {(globalTraffic.up / globalTraffic.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], + [items, currentIndex, opened], + ); + const t = useScopedI18n("widget.downloads.states"); + //The use case for "No item found" should be impossible, hence no translation + return ( + + {item === undefined ? ( +
{"No item found"}
+ ) : ( + + {item.name} + + + {`${item.integration.name} (${item.integration.kind})`} + + + + + + + + + + + + + + + + )} +
+ ); +}; + +const NormalizedLine = ({ + itemKey, + values, +}: { + itemKey: Exclude; + values?: number | string | string[]; +}) => { + 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") }); + if (typeof values !== "number" && (values === undefined || values.length === 0)) return null; + return ( + + {keyString} + {Array.isArray(values) ? ( + + {values.map((value) => ( + {value} + ))} + + ) : ( + {values} + )} + + ); +}; + +interface ClientsControlProps { + clients: ExtendedClientStatus[]; + style?: MantineStyleProp; +} + +const ClientsControl = ({ clients, style }: ClientsControlProps) => { + const integrationsStatuses = clients.reduce( + (acc, { status, integration: { id }, interact }) => + status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc, + { paused: [] as string[], active: [] as string[] }, + ); + const someInteract = clients.some(({ interact }) => interact); + const totalSpeed = humanFileSize( + clients.reduce((count, { status }) => count + (status?.rates.down ?? 0), 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"); + return ( + + + {clients.map((client) => ( + + ))} + + {someInteract && ( + + mutateResumeQueue({ integrationIds: integrationsStatuses.paused })} + > + + + + )} + + {someInteract && ( + + mutatePauseQueue({ integrationIds: integrationsStatuses.active })} + > + + + + )} + + + {clients.map((client) => ( + + + + + + + {client.status ? ( + + + {client.status.rates.up !== undefined ? ( + + + {`↑ ${humanFileSize(client.status.rates.up, "/s")}`} + + {"-"} + + {humanFileSize(client.status.totalUp ?? 0)} + + + ) : undefined} + + + {`↓ ${humanFileSize(client.status.rates.down, "/s")}`} + + {"-"} + + {humanFileSize(Math.floor(client.status.totalDown ?? 0))} + + + + + ) : ( + + {t("errors.noCommunications")} + + )} + + + + {client.integration.name} + + + {client.status && client.interact ? ( + + { + (client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({ + integrationIds: [client.integration.id], + }); + }} + > + {client.status.paused ? : } + + + ) : ( + + + + )} + + + ))} + + + + ); +}; diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts new file mode 100644 index 000000000..1aa15f359 --- /dev/null +++ b/packages/widgets/src/downloads/index.ts @@ -0,0 +1,110 @@ +import { IconDownload } from "@tabler/icons-react"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { ExtendedDownloadClientItem } from "@homarr/integrations"; +import { z } from "@homarr/validation"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +const columnsList = [ + "id", + "actions", + "added", + "category", + "downSpeed", + "index", + "integration", + "name", + "progress", + "ratio", + "received", + "sent", + "size", + "state", + "time", + "type", + "upSpeed", +] as const satisfies (keyof ExtendedDownloadClientItem)[]; +const sortingExclusion = ["actions", "id", "state"] as const satisfies readonly (typeof columnsList)[number][]; +const columnsSort = columnsList.filter((column) => + sortingExclusion.some((exclusion) => exclusion !== column), +) as Exclude; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("downloads", { + icon: IconDownload, + options: optionsBuilder.from( + (factory) => ({ + columns: factory.multiSelect({ + defaultValue: ["integration", "name", "progress", "time", "actions"], + options: columnsList.map((value) => ({ + value, + label: (t) => t(`widget.downloads.items.${value}.columnTitle`), + })), + searchable: true, + }), + enableRowSorting: factory.switch({ + defaultValue: false, + }), + defaultSort: factory.select({ + defaultValue: "type", + options: columnsSort.map((value) => ({ + value, + label: (t) => t(`widget.downloads.items.${value}.columnTitle`), + })), + }), + descendingDefaultSort: factory.switch({ + defaultValue: false, + }), + 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[], + validate: z.string(), + }), + filterIsWhitelist: factory.switch({ + defaultValue: false, + }), + applyFilterToRatio: factory.switch({ + defaultValue: true, + }), + }), + { + defaultSort: { + shouldHide: (options) => !options.enableRowSorting, + }, + descendingDefaultSort: { + shouldHide: (options) => !options.enableRowSorting, + }, + showCompletedUsenet: { + shouldHide: (_, integrationKinds) => + !getIntegrationKindsByCategory("usenet").some((kinds) => integrationKinds.includes(kinds)), + }, + showCompletedTorrent: { + shouldHide: (_, integrationKinds) => + !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)), + }, + activeTorrentThreshold: { + shouldHide: (_, integrationKinds) => + !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)), + }, + applyFilterToRatio: { + shouldHide: (_, integrationKinds) => + !getIntegrationKindsByCategory("torrent").some((kinds) => integrationKinds.includes(kinds)), + }, + }, + ), + supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), +}) + .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..8259fc5ac --- /dev/null +++ b/packages/widgets/src/downloads/serverData.ts @@ -0,0 +1,21 @@ +"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: undefined, + }; + } + + const jobsAndStatuses = await api.widget.downloads.getJobsAndStatuses({ + integrationIds, + }); + + return { + initialData: jobsAndStatuses, + }; +} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 4e3d42033..4c1b04b8c 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -11,6 +11,7 @@ import * as clock from "./clock"; import type { WidgetComponentProps } from "./definition"; import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; +import * as downloads from "./downloads"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; import * as indexerManager from "./indexer-manager"; @@ -45,6 +46,7 @@ export const widgetImports = { "smartHome-executeAutomation": smartHomeExecuteAutomation, mediaServer, calendar, + downloads, "mediaRequests-requestList": mediaRequestsList, "mediaRequests-requestStats": mediaRequestsStats, rssFeed, diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index 72c76876e..9885a1764 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -1,5 +1,7 @@ import { IconReportSearch, IconServerOff } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -10,7 +12,7 @@ export const { definition, componentLoader, serverDataLoader } = createWidgetDef defaultValue: true, }), })), - supportedIntegrations: ["prowlarr"], + supportedIntegrations: getIntegrationKindsByCategory("indexerManager"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 4fe23f895..47ad762d9 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,5 +1,7 @@ import { IconZoomQuestion } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -10,7 +12,7 @@ export const { componentLoader, definition, serverDataLoader } = createWidgetDef defaultValue: true, }), })), - supportedIntegrations: ["overseerr", "jellyseerr"], + supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }) .withServerData(() => import("./serverData")) .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 332a0885b..3d5576147 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,11 +1,13 @@ import { IconChartBar } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; export const { componentLoader, definition, serverDataLoader } = createWidgetDefinition("mediaRequests-requestStats", { icon: IconChartBar, options: {}, - supportedIntegrations: ["overseerr", "jellyseerr"], + supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }) .withServerData(() => import("./serverData")) .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx index 574911492..4a344bcf5 100644 --- a/packages/widgets/src/modals/widget-edit-modal.tsx +++ b/packages/widgets/src/modals/widget-edit-modal.tsx @@ -47,9 +47,9 @@ export const WidgetEditModal = createModal>(({ actions, i z.object({ options: z.object( objectEntries(widgetImports[innerProps.kind].definition.options).reduce( - (acc, [key, value]: [string, { validate?: z.ZodType }]) => { + (acc, [key, value]: [string, { type: string; validate?: z.ZodType }]) => { if (value.validate) { - acc[key] = value.validate; + acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate; } return acc; diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index 300188e5f..4bfdaaaff 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -1,5 +1,7 @@ import { IconBinaryTree } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -15,5 +17,5 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome entityUnit: factory.text(), clickable: factory.switch(), })), - supportedIntegrations: ["homeAssistant"], + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts index a1996e9fe..96c6f8394 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -1,5 +1,7 @@ import { IconBinaryTree } from "@tabler/icons-react"; +import { getIntegrationKindsByCategory } from "@homarr/definitions"; + import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -9,5 +11,5 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome displayName: factory.text(), automationId: factory.text(), })), - supportedIntegrations: ["homeAssistant"], + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/widgets-common.css b/packages/widgets/src/widgets-common.css new file mode 100644 index 000000000..0418074fe --- /dev/null +++ b/packages/widgets/src/widgets-common.css @@ -0,0 +1,36 @@ +.downloads-widget-table { + /*Set Header static and overflow body instead*/ + display: flex; + height: 100%; + flex-direction: column; + .mantine-Table-tbody { + overflow-y: auto; + flex: 1; + scrollbar-width: 0; + } + /*Hide scrollbar until I can apply an overlay scrollbar instead*/ + .mantine-Table-tbody::-webkit-scrollbar { + width: 0; + } + /*Properly size header*/ + .mrt-table-head-cell-labels { + min-height: var(--ratioWidth); + gap: 0; + padding: 0; + } + /*Properly size controls*/ + .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 1755440f8..2397b8fa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,10 +21,10 @@ importers: version: 2.1.1(@types/node@20.16.5)(typescript@5.6.2) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.3.1(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)) + version: 4.3.1(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0)) '@vitest/coverage-v8': specifier: ^2.0.5 - version: 2.0.5(vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)) + version: 2.0.5(vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0)) '@vitest/ui': specifier: ^2.0.5 version: 2.0.5(vitest@2.0.5) @@ -48,10 +48,10 @@ importers: version: 5.6.2 vite-tsconfig-paths: specifier: ^5.0.1 - version: 5.0.1(typescript@5.6.2)(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)) + version: 5.0.1(typescript@5.6.2)(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0)) vitest: specifier: ^2.0.5 - version: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + version: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) apps/nextjs: dependencies: @@ -132,7 +132,7 @@ importers: version: 7.12.2(@mantine/core@7.12.2(@mantine/hooks@7.12.2(react@18.3.1))(@types/react@18.3.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@7.12.2(react@18.3.1))(@tiptap/extension-link@2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6))(@tiptap/react@2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@million/lint': specifier: 1.0.0-rc.84 - version: 1.0.0-rc.84(rollup@4.17.2) + version: 1.0.0-rc.84(encoding@0.1.13)(rollup@4.17.2) '@t3-oss/env-nextjs': specifier: ^0.11.1 version: 0.11.1(typescript@5.6.2)(zod@3.23.8) @@ -198,7 +198,7 @@ importers: version: 14.2.9(@babel/core@7.24.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.78.0) postcss-preset-mantine: specifier: ^1.17.0 - version: 1.17.0(postcss@8.4.38) + version: 1.17.0(postcss@8.4.45) prismjs: specifier: ^1.29.0 version: 1.29.0 @@ -572,7 +572,7 @@ importers: version: 0.11.1(typescript@5.6.2)(zod@3.23.8) bcrypt: specifier: ^5.1.1 - version: 5.1.1 + version: 5.1.1(encoding@0.1.13) cookies: specifier: ^0.9.1 version: 0.9.1 @@ -736,7 +736,7 @@ importers: dependencies: '@extractus/feed-extractor': specifier: ^7.1.3 - version: 7.1.3 + version: 7.1.3(encoding@0.1.13) '@homarr/analytics': specifier: workspace:^0.1.0 version: link:../analytics @@ -956,9 +956,21 @@ importers: packages/integrations: dependencies: + '@ctrl/deluge': + specifier: ^6.1.0 + version: 6.1.0 + '@ctrl/qbittorrent': + specifier: ^9.0.1 + version: 9.0.1 + '@ctrl/transmission': + specifier: ^6.1.0 + version: 6.1.0 '@homarr/common': specifier: workspace:^0.1.0 version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db '@homarr/definitions': specifier: workspace:^0.1.0 version: link:../definitions @@ -974,6 +986,9 @@ importers: '@jellyfin/sdk': specifier: ^0.10.0 version: 0.10.0(axios@1.7.2) + typed-rpc: + specifier: ^5.1.0 + version: 5.1.0 devDependencies: '@homarr/eslint-config': specifier: workspace:^0.2.0 @@ -1393,13 +1408,19 @@ importers: dependencies: '@extractus/feed-extractor': specifier: ^7.1.3 - version: 7.1.3 + version: 7.1.3(encoding@0.1.13) '@homarr/api': specifier: workspace:^0.1.0 version: link:../api + '@homarr/auth': + specifier: workspace:^0.1.0 + version: link:../auth '@homarr/common': specifier: workspace:^0.1.0 version: link:../common + '@homarr/db': + specifier: workspace:^0.1.0 + version: link:../db '@homarr/definitions': specifier: workspace:^0.1.0 version: link:../definitions @@ -1673,10 +1694,18 @@ packages: resolution: {integrity: sha512-WdJjwMEkmBicq5T9fm/cHND3+UlFa2Yj8ALLgmoSQAJZysYbBjw+azChSGPN4DSPLXOcooGRvDwZWMcF/mLO2Q==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.24.8': + resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.6': resolution: {integrity: sha512-4yA7s865JHaqUdRbnaxarZREuPTHrjpDT+pXoAZ1yhyo6uFnIEpS8VMu16siFOHDpZNKYv5BObhsB//ycbICyw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.24.7': + resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.24.6': resolution: {integrity: sha512-Jktc8KkF3zIkePb48QO+IapbXlSapOW9S+ogZZkcO6bABgYAxtZcjZ/O005111YLf+j4M84uEgwYoidDkXbCkQ==} engines: {node: '>=6.9.0'} @@ -1694,6 +1723,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.25.6': + resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-transform-react-jsx-self@7.24.5': resolution: {integrity: sha512-RtCJoUO2oYrYwFPtR1/jkoBEcFuI1ae9a9IMxeyAVa3a1Ap4AnxmyIKG2b2FaJKqkidw/0cxRbWN+HOs6ZWd1w==} engines: {node: '>=6.9.0'} @@ -1726,6 +1760,10 @@ packages: resolution: {integrity: sha512-WaMsgi6Q8zMgMth93GvWPXkhAIEobfsIkLTacoVZoK1J0CevIPGYY2Vo5YvJGqyHqXM6P4ppOYGsIRU8MM9pFQ==} engines: {node: '>=6.9.0'} + '@babel/types@7.25.6': + resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1751,6 +1789,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@9.0.1': + resolution: {integrity: sha512-MaQhyccZ30C1V8Uxqhc1NvrM/Lgb8x6AunIxjlbhYhw5Zx/l8G2etbjTKle3RIFExURKmzrJx7Odj3EM4AlqDQ==} + engines: {node: '>=18'} + + '@ctrl/shared-torrent@6.0.0': + resolution: {integrity: sha512-BZAPDv8syFArFTAAeb560JSBNTajFtP3G/5eYiUMsg0upGAQs6NWGiHYbyjvAt8uHCSzxXsiji/Wvq1b7CvXSQ==} + engines: {node: '>=18'} + + '@ctrl/torrent-file@4.1.0': + resolution: {integrity: sha512-mC6HdmCrRhhwpthM+OboJvGIywVR05IbdhVSBkfbGslzbQk2xNnx4UOKljV/x2YI2M1DDF3F3o0paIiYd5O0Og==} + 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==} @@ -2291,6 +2353,9 @@ packages: '@jridgewell/sourcemap-codec@1.4.15': resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -3062,8 +3127,8 @@ packages: '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - '@types/eslint@8.56.10': - resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -3400,6 +3465,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + aes-decrypter@4.0.1: resolution: {integrity: sha512-H1nh/P9VZXUf17AA5NQfJML88CFjVBDuGkp5zDHa7oEhYN9TTpNLJknRY1ie0iSKWlDf6JRnJKaZVDSQdPy6Cg==} @@ -3647,6 +3717,11 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + browserslist@4.23.3: + resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} @@ -3702,6 +3777,9 @@ packages: caniuse-lite@1.0.30001620: resolution: {integrity: sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==} + caniuse-lite@1.0.30001660: + resolution: {integrity: sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==} + chai@5.1.1: resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} engines: {node: '>=12'} @@ -4100,6 +4178,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destr@2.0.3: + resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -4278,6 +4359,9 @@ packages: electron-to-chromium@1.4.772: resolution: {integrity: sha512-jFfEbxR/abTTJA3ci+2ok1NTuOBBtB4jH+UT6PUmRN+DY3WSD4FFRsgoVQ+QNIJ0T7wrXwzsWCI2WKC46b++2A==} + electron-to-chromium@1.5.18: + resolution: {integrity: sha512-1OfuVACu+zKlmjsNdcJuVQuVE61sZOLbNM4JAQ1Rvh6EOj0/EUKhMJjRH73InPlXSh8HIJk1cVZ8pyOV/FMdUQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -4291,6 +4375,9 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} @@ -4301,8 +4388,8 @@ packages: resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} engines: {node: '>=10.0.0'} - enhanced-resolve@5.16.1: - resolution: {integrity: sha512-4U5pNsuDl0EhuZpq46M5xPslstkviJuhrdobaRDBk2Jy2KO37FDAJl4lb2KlNabxT0m4MTK2UHNrsAcphE8nyw==} + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -4328,8 +4415,8 @@ packages: resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} engines: {node: '>= 0.4'} - es-module-lexer@1.5.3: - resolution: {integrity: sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg==} + es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} es-object-atoms@1.0.0: resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} @@ -4370,6 +4457,10 @@ packages: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-goat@2.1.1: resolution: {integrity: sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==} engines: {node: '>=8'} @@ -5416,6 +5507,9 @@ packages: magic-string@0.30.10: resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} + magic-string@0.30.11: + resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magicast@0.3.4: resolution: {integrity: sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==} @@ -5686,6 +5780,9 @@ packages: resolution: {integrity: sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-fetch-native@1.6.4: + resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -5716,6 +5813,9 @@ packages: node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -5778,6 +5878,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==} @@ -5919,6 +6022,9 @@ packages: picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -5975,6 +6081,10 @@ packages: resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} engines: {node: ^10 || ^12 || >=14} + postcss@8.4.45: + resolution: {integrity: sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==} + engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@5.2.3: resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} peerDependencies: @@ -6394,6 +6504,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 @@ -6607,6 +6720,10 @@ packages: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -6833,8 +6950,8 @@ packages: uglify-js: optional: true - terser@5.31.0: - resolution: {integrity: sha512-Q1JFAoUKE5IMfI4Z/lkE/E6+SwgzO+x4tq4v1AyBLRj8VSYvRO6A/rQrPg1yud4g0En9EKI1TvFRF2tQFcoUkg==} + terser@5.32.0: + resolution: {integrity: sha512-v3Gtw3IzpBJ0ugkxEX8U0W6+TnPKRRCWGh1jC/iM/e3Ki5+qvO1L1EAZ56bZasc64aXHwRHNIQEzm6//i5cemQ==} engines: {node: '>=10'} hasBin: true @@ -7087,6 +7204,9 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} + typed-rpc@5.1.0: + resolution: {integrity: sha512-qPWUQrLye3Z5kQ8GuVLIURIUNPaDrKgBBts5nXVjR87j8+4sva/sw7lmaBfOw+7m/S4F9jplkv9XZl0hUDUHZg==} + typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -7110,11 +7230,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==} @@ -7161,6 +7288,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.1.0: + resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + update-check@1.5.4: resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} @@ -7347,8 +7480,8 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} - watchpack@2.4.1: - resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} + watchpack@2.4.2: + resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} engines: {node: '>=10.13.0'} wcwidth@1.0.1: @@ -7681,8 +7814,12 @@ snapshots: '@babel/helper-string-parser@7.24.6': {} + '@babel/helper-string-parser@7.24.8': {} + '@babel/helper-validator-identifier@7.24.6': {} + '@babel/helper-validator-identifier@7.24.7': {} + '@babel/helper-validator-option@7.24.6': {} '@babel/helpers@7.24.6': @@ -7701,6 +7838,11 @@ snapshots: dependencies: '@babel/types': 7.24.6 + '@babel/parser@7.25.6': + dependencies: + '@babel/types': 7.25.6 + optional: true + '@babel/plugin-transform-react-jsx-self@7.24.5(@babel/core@7.24.6)': dependencies: '@babel/core': 7.24.6 @@ -7747,6 +7889,12 @@ snapshots: '@babel/helper-validator-identifier': 7.24.6 to-fast-properties: 2.0.0 + '@babel/types@7.25.6': + dependencies: + '@babel/helper-string-parser': 7.24.8 + '@babel/helper-validator-identifier': 7.24.7 + to-fast-properties: 2.0.0 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@0.2.3': {} @@ -7755,13 +7903,13 @@ snapshots: '@clack/core@0.3.4': dependencies: - picocolors: 1.0.1 + picocolors: 1.1.0 sisteransi: 1.0.5 '@clack/prompts@0.7.0': dependencies: '@clack/core': 0.3.4 - picocolors: 1.0.1 + picocolors: 1.1.0 sisteransi: 1.0.5 '@colors/colors@1.6.0': {} @@ -7770,6 +7918,46 @@ 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@9.0.1': + dependencies: + '@ctrl/magnet-link': 4.0.2 + '@ctrl/shared-torrent': 6.0.0 + '@ctrl/torrent-file': 4.1.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.1.0': + dependencies: + 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 @@ -8029,10 +8217,10 @@ snapshots: dependencies: levn: 0.4.1 - '@extractus/feed-extractor@7.1.3': + '@extractus/feed-extractor@7.1.3(encoding@0.1.13)': dependencies: bellajs: 11.2.0 - cross-fetch: 4.0.0 + cross-fetch: 4.0.0(encoding@0.1.13) fast-xml-parser: 4.4.0 html-entities: 2.5.2 transitivePeerDependencies: @@ -8123,6 +8311,9 @@ snapshots: '@jridgewell/sourcemap-codec@1.4.15': {} + '@jridgewell/sourcemap-codec@1.5.0': + optional: true + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -8207,12 +8398,12 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@mapbox/node-pre-gyp@1.0.11': + '@mapbox/node-pre-gyp@1.0.11(encoding@0.1.13)': dependencies: detect-libc: 2.0.3 https-proxy-agent: 5.0.1 make-dir: 3.1.0 - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 @@ -8227,7 +8418,7 @@ snapshots: '@antfu/ni': 0.21.12 '@axiomhq/js': 1.0.0-rc.3 '@babel/core': 7.24.6 - '@babel/types': 7.24.6 + '@babel/types': 7.25.6 '@clack/prompts': 0.7.0 cli-high: 0.4.2 diff: 5.2.0 @@ -8236,7 +8427,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@million/lint@1.0.0-rc.84(rollup@4.17.2)': + '@million/lint@1.0.0-rc.84(encoding@0.1.13)(rollup@4.17.2)': dependencies: '@axiomhq/js': 1.0.0-rc.3 '@babel/core': 7.24.6 @@ -8249,7 +8440,7 @@ snapshots: ci-info: 4.0.0 esbuild: 0.20.2 hono: 4.4.0 - isomorphic-fetch: 3.0.0 + isomorphic-fetch: 3.0.0(encoding@0.1.13) nanoid: 5.0.7 pako: 2.1.0 pathe: 1.1.2 @@ -8261,7 +8452,7 @@ snapshots: socket.io-client: 4.7.5 tmp: 0.2.3 unplugin: 1.10.1 - update-notifier-cjs: 5.1.6 + update-notifier-cjs: 5.1.6(encoding@0.1.13) transitivePeerDependencies: - bufferutil - encoding @@ -9157,10 +9348,10 @@ snapshots: '@types/eslint-scope@3.7.7': dependencies: - '@types/eslint': 8.56.10 + '@types/eslint': 9.6.1 '@types/estree': 1.0.5 - '@types/eslint@8.56.10': + '@types/eslint@9.6.1': dependencies: '@types/estree': 1.0.5 '@types/json-schema': 7.0.15 @@ -9398,18 +9589,18 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-react@4.3.1(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))': + '@vitejs/plugin-react@4.3.1(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0))': dependencies: '@babel/core': 7.24.6 '@babel/plugin-transform-react-jsx-self': 7.24.5(@babel/core@7.24.6) '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.24.6) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0))': + '@vitest/coverage-v8@2.0.5(vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 @@ -9423,7 +9614,7 @@ snapshots: std-env: 3.7.0 test-exclude: 7.0.1 tinyrainbow: 1.2.0 - vitest: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vitest: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) transitivePeerDependencies: - supports-color @@ -9462,7 +9653,7 @@ snapshots: pathe: 1.1.2 sirv: 2.0.4 tinyrainbow: 1.2.0 - vitest: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vitest: 2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) '@vitest/utils@2.0.5': dependencies: @@ -9473,11 +9664,11 @@ snapshots: '@vue/compiler-core@3.4.31': dependencies: - '@babel/parser': 7.24.7 + '@babel/parser': 7.25.6 '@vue/shared': 3.4.31 entities: 4.5.0 estree-walker: 2.0.2 - source-map-js: 1.2.0 + source-map-js: 1.2.1 optional: true '@vue/compiler-dom@3.4.31': @@ -9488,15 +9679,15 @@ snapshots: '@vue/compiler-sfc@3.4.31': dependencies: - '@babel/parser': 7.24.7 + '@babel/parser': 7.25.6 '@vue/compiler-core': 3.4.31 '@vue/compiler-dom': 3.4.31 '@vue/compiler-ssr': 3.4.31 '@vue/shared': 3.4.31 estree-walker: 2.0.2 - magic-string: 0.30.10 - postcss: 8.4.38 - source-map-js: 1.2.0 + magic-string: 0.30.11 + postcss: 8.4.45 + source-map-js: 1.2.1 optional: true '@vue/compiler-ssr@3.4.31': @@ -9613,9 +9804,9 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 - acorn-import-assertions@1.9.0(acorn@8.12.0): + acorn-import-assertions@1.9.0(acorn@8.12.1): dependencies: - acorn: 8.12.0 + acorn: 8.12.1 acorn-jsx@5.3.2(acorn@8.12.0): dependencies: @@ -9625,6 +9816,8 @@ snapshots: acorn@8.12.0: {} + acorn@8.12.1: {} + aes-decrypter@4.0.1: dependencies: '@babel/runtime': 7.24.5 @@ -9876,9 +10069,9 @@ snapshots: dependencies: tweetnacl: 0.14.5 - bcrypt@5.1.1: + bcrypt@5.1.1(encoding@0.1.13): dependencies: - '@mapbox/node-pre-gyp': 1.0.11 + '@mapbox/node-pre-gyp': 1.0.11(encoding@0.1.13) node-addon-api: 5.1.0 transitivePeerDependencies: - encoding @@ -9936,6 +10129,13 @@ snapshots: node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.0) + browserslist@4.23.3: + dependencies: + caniuse-lite: 1.0.30001660 + electron-to-chromium: 1.5.18 + node-releases: 2.0.18 + update-browserslist-db: 1.1.0(browserslist@4.23.3) + buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} @@ -9984,6 +10184,8 @@ snapshots: caniuse-lite@1.0.30001620: {} + caniuse-lite@1.0.30001660: {} + chai@5.1.1: dependencies: assertion-error: 2.0.1 @@ -10240,9 +10442,9 @@ snapshots: dependencies: cross-spawn: 7.0.3 - cross-fetch@4.0.0: + cross-fetch@4.0.0(encoding@0.1.13): dependencies: - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) transitivePeerDependencies: - encoding @@ -10393,6 +10595,8 @@ snapshots: dequal@2.0.3: {} + destr@2.0.3: {} + detect-libc@2.0.3: {} detect-node-es@1.1.0: {} @@ -10501,6 +10705,8 @@ snapshots: electron-to-chromium@1.4.772: {} + electron-to-chromium@1.5.18: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -10509,6 +10715,11 @@ snapshots: enabled@2.0.0: {} + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + end-of-stream@1.4.4: dependencies: once: 1.4.0 @@ -10527,7 +10738,7 @@ snapshots: engine.io-parser@5.2.3: {} - enhanced-resolve@5.16.1: + enhanced-resolve@5.17.1: dependencies: graceful-fs: 4.2.11 tapable: 2.2.1 @@ -10618,7 +10829,7 @@ snapshots: iterator.prototype: 1.1.2 safe-array-concat: 1.1.2 - es-module-lexer@1.5.3: {} + es-module-lexer@1.5.4: {} es-object-atoms@1.0.0: dependencies: @@ -10726,6 +10937,8 @@ snapshots: escalade@3.1.2: {} + escalade@3.2.0: {} + escape-goat@2.1.1: {} escape-string-regexp@1.0.5: {} @@ -11609,9 +11822,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-fetch@3.0.0: + isomorphic-fetch@3.0.0(encoding@0.1.13): dependencies: - node-fetch: 2.7.0 + node-fetch: 2.7.0(encoding@0.1.13) whatwg-fetch: 3.6.20 transitivePeerDependencies: - encoding @@ -11874,6 +12087,11 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.4.15 + magic-string@0.30.11: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + optional: true + magicast@0.3.4: dependencies: '@babel/parser': 7.24.7 @@ -12120,9 +12338,13 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 - node-fetch@2.7.0: + node-fetch-native@1.6.4: {} + + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 + optionalDependencies: + encoding: 0.1.13 node-gyp-build@4.8.1: optional: true @@ -12163,6 +12385,8 @@ snapshots: node-releases@2.0.14: {} + node-releases@2.0.18: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -12231,6 +12455,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 @@ -12391,6 +12621,8 @@ snapshots: picocolors@1.0.1: {} + picocolors@1.1.0: {} + picomatch@2.3.1: {} piscina@4.5.1: @@ -12403,38 +12635,38 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-js@4.0.1(postcss@8.4.38): + postcss-js@4.0.1(postcss@8.4.45): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.38 + postcss: 8.4.45 - postcss-mixins@9.0.4(postcss@8.4.38): + postcss-mixins@9.0.4(postcss@8.4.45): dependencies: fast-glob: 3.3.2 - postcss: 8.4.38 - postcss-js: 4.0.1(postcss@8.4.38) - postcss-simple-vars: 7.0.1(postcss@8.4.38) - sugarss: 4.0.1(postcss@8.4.38) + postcss: 8.4.45 + postcss-js: 4.0.1(postcss@8.4.45) + postcss-simple-vars: 7.0.1(postcss@8.4.45) + sugarss: 4.0.1(postcss@8.4.45) - postcss-nested@6.0.1(postcss@8.4.38): + postcss-nested@6.0.1(postcss@8.4.45): dependencies: - postcss: 8.4.38 + postcss: 8.4.45 postcss-selector-parser: 6.0.16 - postcss-preset-mantine@1.17.0(postcss@8.4.38): + postcss-preset-mantine@1.17.0(postcss@8.4.45): dependencies: - postcss: 8.4.38 - postcss-mixins: 9.0.4(postcss@8.4.38) - postcss-nested: 6.0.1(postcss@8.4.38) + postcss: 8.4.45 + postcss-mixins: 9.0.4(postcss@8.4.45) + postcss-nested: 6.0.1(postcss@8.4.45) postcss-selector-parser@6.0.16: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-simple-vars@7.0.1(postcss@8.4.38): + postcss-simple-vars@7.0.1(postcss@8.4.45): dependencies: - postcss: 8.4.38 + postcss: 8.4.45 postcss@8.4.31: dependencies: @@ -12448,6 +12680,12 @@ snapshots: picocolors: 1.0.1 source-map-js: 1.2.0 + postcss@8.4.45: + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + preact-render-to-string@5.2.3(preact@10.11.3): dependencies: preact: 10.11.3 @@ -12932,6 +13170,8 @@ snapshots: reusify@1.0.4: {} + rfc4648@1.5.3: {} + rimraf@3.0.2: dependencies: glob: 7.2.3 @@ -13181,6 +13421,8 @@ snapshots: source-map-js@1.2.0: {} + source-map-js@1.2.1: {} + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -13330,9 +13572,9 @@ snapshots: sugar-high@0.6.1: {} - sugarss@4.0.1(postcss@8.4.38): + sugarss@4.0.1(postcss@8.4.45): dependencies: - postcss: 8.4.38 + postcss: 8.4.45 superjson@2.2.1: dependencies: @@ -13479,13 +13721,13 @@ snapshots: jest-worker: 27.5.1 schema-utils: 3.3.0 serialize-javascript: 6.0.2 - terser: 5.31.0 + terser: 5.32.0 webpack: 5.91.0 - terser@5.31.0: + terser@5.32.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.12.0 + acorn: 8.12.1 commander: 2.20.3 source-map-support: 0.5.21 @@ -13750,6 +13992,8 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 + typed-rpc@5.1.0: {} + typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 @@ -13773,9 +14017,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 @@ -13818,12 +14066,18 @@ snapshots: escalade: 3.1.2 picocolors: 1.0.1 + update-browserslist-db@1.1.0(browserslist@4.23.3): + dependencies: + browserslist: 4.23.3 + escalade: 3.2.0 + picocolors: 1.1.0 + update-check@1.5.4: dependencies: registry-auth-token: 3.3.2 registry-url: 3.1.0 - update-notifier-cjs@5.1.6: + update-notifier-cjs@5.1.6(encoding@0.1.13): dependencies: boxen: 5.1.2 chalk: 4.1.2 @@ -13834,7 +14088,7 @@ snapshots: is-installed-globally: 0.4.0 is-npm: 5.0.0 is-yarn-global: 0.3.0 - isomorphic-fetch: 3.0.0 + isomorphic-fetch: 3.0.0(encoding@0.1.13) pupa: 2.1.1 registry-auth-token: 5.0.2 registry-url: 5.1.0 @@ -13941,13 +14195,13 @@ snapshots: dependencies: global: 4.4.0 - vite-node@2.0.5(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0): + vite-node@2.0.5(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0): dependencies: cac: 6.7.14 debug: 4.3.7 pathe: 1.1.2 tinyrainbow: 1.2.0 - vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) transitivePeerDependencies: - '@types/node' - less @@ -13958,18 +14212,18 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@5.0.1(typescript@5.6.2)(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0)): + vite-tsconfig-paths@5.0.1(typescript@5.6.2)(vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0)): dependencies: debug: 4.3.5 globrex: 0.1.2 tsconfck: 3.0.3(typescript@5.6.2) optionalDependencies: - vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) transitivePeerDependencies: - supports-color - typescript - vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0): + vite@5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0): dependencies: esbuild: 0.20.2 postcss: 8.4.38 @@ -13978,10 +14232,10 @@ snapshots: '@types/node': 20.16.5 fsevents: 2.3.3 sass: 1.78.0 - sugarss: 4.0.1(postcss@8.4.38) - terser: 5.31.0 + sugarss: 4.0.1(postcss@8.4.45) + terser: 5.32.0 - vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0): + vitest@2.0.5(@types/node@20.16.5)(@vitest/ui@2.0.5)(jsdom@25.0.0)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0): dependencies: '@ampproject/remapping': 2.3.0 '@vitest/expect': 2.0.5 @@ -13999,8 +14253,8 @@ snapshots: tinybench: 2.8.0 tinypool: 1.0.0 tinyrainbow: 1.2.0 - vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) - vite-node: 2.0.5(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.38))(terser@5.31.0) + vite: 5.2.11(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) + vite-node: 2.0.5(@types/node@20.16.5)(sass@1.78.0)(sugarss@4.0.1(postcss@8.4.45))(terser@5.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.16.5 @@ -14021,7 +14275,7 @@ snapshots: dependencies: xml-name-validator: 5.0.0 - watchpack@2.4.1: + watchpack@2.4.2: dependencies: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 @@ -14050,12 +14304,12 @@ snapshots: '@webassemblyjs/ast': 1.12.1 '@webassemblyjs/wasm-edit': 1.12.1 '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.12.0 - acorn-import-assertions: 1.9.0(acorn@8.12.0) - browserslist: 4.23.0 + acorn: 8.12.1 + acorn-import-assertions: 1.9.0(acorn@8.12.1) + browserslist: 4.23.3 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.16.1 - es-module-lexer: 1.5.3 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1 @@ -14067,7 +14321,7 @@ snapshots: schema-utils: 3.3.0 tapable: 2.2.1 terser-webpack-plugin: 5.3.10(webpack@5.91.0) - watchpack: 2.4.1 + watchpack: 2.4.2 webpack-sources: 3.2.3 transitivePeerDependencies: - '@swc/core'