mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 08:20:56 +01:00
feat: downloads widget (#844)
Usenet and Torrent downloads in 1 widget. sabNZBd, NzbGet, Deluge, qBitTorrent, and transmission support. Columns can be reordered in Edit mode. Sorting enabled. Time uses Dayjs with auto translation. Can pause/resume single items, clients, or all. Can delete items (With option to delete assossiated files). Clients list and details. Include all filtering and processing for ratio from oldmarr torrent widget. Invalidation of old data (older than 30 seconds) to show an integration is not responding anymore. Misc (So many miscs): Fixed validation error with multiText. Fixed translation application for multiSelect to behave the same as select. Added background to gitignore (I needed to add a background to visually test opacity, probably will in the future too) Added setOptions to frontend components so potential updates made from the Dashboard can be saved. Extracted background and border color to use in widgets. humanFileSize function based on the si format (powers of 1024, not 1000). Improved integrationCreatorByKind by @Meierschlumpf. Changed integrationCreatorByKind to integrationCreator so it functions directly from the integration. Added integrationCreatorFromSecrets to directly work with secrets from db. Added getIntegrationKindsByCategory to get a list of integrations sharing categories. Added IntegrationKindByCategory type to get the types possible for a category (Great to cast on integration.kind that isn't already properly limited/typed but for which we know the limitation) Added a common AtLeastOneOf type. Applied to TKind and IntegrationSecretKind[] where it was already being used and Added to the getIntegrationKindsByCategory's output to be more freely used. Added the Modify type, instead of omiting to then add again just to change a parameters type, use the modify instead. Applied code wide already. Hook to get list of integration depending on permission level of user. (By @Meierschlumpf)
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -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",
|
||||
|
||||
@@ -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 = <TParams extends Record<string, unknown>>(
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
isBoardContentPage: true,
|
||||
}),
|
||||
page: () => {
|
||||
return <ClientBoard />;
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
page: async () => {
|
||||
const session = await auth();
|
||||
const integrations = await getIntegrationsWithPermissionsAsync(session);
|
||||
|
||||
return (
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<ClientBoard />
|
||||
</IntegrationProvider>
|
||||
);
|
||||
},
|
||||
generateMetadataAsync: async ({ params }: { params: TParams }): Promise<Metadata> => {
|
||||
try {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = <TFormValue extends Record<string, boolean>>({
|
||||
@@ -13,9 +14,12 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: Omit<UseFormReturnType<TFormValue, () => TFormValue>, "setFieldValue"> & {
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
};
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
|
||||
@@ -86,6 +86,9 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
});
|
||||
}, [dimensions, openPreviewDimensionsModal]);
|
||||
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
setState({ ...state, options: { ...state.options, newOptions } });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card withBorder w={dimensions.width} h={dimensions.height} p={dimensions.height >= 96 ? undefined : 4}>
|
||||
@@ -105,6 +108,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
|
||||
isEditMode={editMode}
|
||||
boardId={undefined}
|
||||
itemId={undefined}
|
||||
setOptions={updateOptions}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -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<Item, "kind" | "yOffset" | "xOffset"> & {
|
||||
kind: WidgetKind;
|
||||
};
|
||||
} satisfies Modify<
|
||||
Omit<Item, "yOffset" | "xOffset">,
|
||||
{
|
||||
kind: WidgetKind;
|
||||
}
|
||||
>;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
@@ -105,7 +109,7 @@ export const useItemActions = () => {
|
||||
id: createId(),
|
||||
yOffset: undefined,
|
||||
xOffset: undefined,
|
||||
} satisfies Omit<Item, "yOffset" | "xOffset"> & { yOffset?: number; xOffset?: number };
|
||||
} satisfies Modify<Item, { yOffset?: number; xOffset?: number }>;
|
||||
|
||||
return {
|
||||
...previous,
|
||||
|
||||
@@ -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<string, unknown> }) =>
|
||||
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}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
|
||||
...kinds: AtLeastOneOf<TKind> // 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 = <TKind extends IntegrationKind>(
|
||||
*/
|
||||
export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
|
||||
...kinds: AtLeastOneOf<TKind> // 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 = <TKind extends IntegrationKind>(
|
||||
*/
|
||||
export const createManyIntegrationOfOneItemMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: [TKind, ...TKind[]] // Ensure at least one kind is provided
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()).min(1), itemId: z.string() }))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof import("@homarr/common")>();
|
||||
const actual = await importActual<typeof import("@homarr/common/server")>();
|
||||
|
||||
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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<DnsHoleSummary>(`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);
|
||||
}),
|
||||
});
|
||||
|
||||
110
packages/api/src/router/widgets/downloads.ts
Normal file
110
packages/api/src/router/widgets/downloads.ts
Normal file
@@ -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<DownloadClientJobsAndStatus>("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<DownloadClientJobsAndStatus>("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);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { signIn, signOut, useSession, SessionProvider } from "next-auth/react";
|
||||
export * from "./permissions/integration-provider";
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
54
packages/auth/permissions/integration-provider.tsx
Normal file
54
packages/auth/permissions/integration-provider.tsx
Normal file
@@ -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<IntegrationContextProps | null>(null);
|
||||
|
||||
export const IntegrationProvider = ({ integrations, children }: PropsWithChildren<IntegrationContextProps>) => {
|
||||
return <IntegrationContext.Provider value={{ integrations }}>{children}</IntegrationContext.Provider>;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
36
packages/auth/permissions/integrations-with-permissions.ts
Normal file
36
packages/auth/permissions/integrations-with-permissions.ts
Normal file
@@ -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),
|
||||
}));
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
|
||||
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
|
||||
export { isProviderEnabled } from "./providers/check-provider";
|
||||
|
||||
@@ -7,5 +7,4 @@ export * from "./hooks";
|
||||
export * from "./url";
|
||||
export * from "./number";
|
||||
export * from "./error";
|
||||
export * from "./encryption";
|
||||
export * from "./fetch-with-timeout";
|
||||
|
||||
@@ -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 "∞";
|
||||
};
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./app-url/server";
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export type AtLeastOneOf<T> = [T, ...T[]];
|
||||
|
||||
export type Modify<T, R extends Partial<Record<keyof T, unknown>>> = {
|
||||
[P in keyof (Omit<T, keyof R> & R)]: (Omit<T, keyof R> & R)[P];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
packages/cron-jobs/src/jobs/integrations/downloads.ts
Normal file
27
packages/cron-jobs/src/jobs/integrations/downloads.ts
Normal file
@@ -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<DownloadClientJobsAndStatus>("downloads", integration.id);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
})
|
||||
.catch((error) => console.error(`Could not retrieve data for ${integration.name}: "${error}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<WidgetComponentProps<"calendar">["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<typeof integration, { kind: "sonarr" | "radarr" }>,
|
||||
);
|
||||
|
||||
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
||||
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.id);
|
||||
await cache.setAsync(events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MediaRequestList>(
|
||||
"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<MediaRequestStats>(
|
||||
"mediaRequests-requestStats",
|
||||
integrationId,
|
||||
integration.id,
|
||||
);
|
||||
await requestStatsChannel.publishAndUpdateLastStateAsync({
|
||||
integration: { kind: integration.kind, name: integration.name },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FeedData, "entries"> {
|
||||
entries?: ExtendedFeedEntry;
|
||||
}
|
||||
type ExtendedFeedData = Modify<
|
||||
FeedData,
|
||||
{
|
||||
entries?: ExtendedFeedEntry;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface RssFeed {
|
||||
feedUrl: string;
|
||||
|
||||
@@ -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<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||
category: AtLeastOneOf<IntegrationCategory>;
|
||||
}
|
||||
|
||||
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<string, integrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
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<IntegrationSecretKind[]> =>
|
||||
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 = <TCategory extends IntegrationCategory>(category: TCategory) => {
|
||||
return objectKeys(integrationDefs).filter((integration) =>
|
||||
integrationDefs[integration].category.some((defCategory) => defCategory === category),
|
||||
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
|
||||
};
|
||||
|
||||
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<TCategory extends IntegrationCategory> = {
|
||||
[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";
|
||||
|
||||
@@ -11,6 +11,7 @@ export const widgetKinds = [
|
||||
"smartHome-executeAutomation",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
"downloads",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"rssFeed",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = <TKind extends keyof typeof integrationCreators>(
|
||||
kind: TKind,
|
||||
integration: IntegrationInput,
|
||||
export const integrationCreator = <TKind extends keyof typeof integrationCreators>(
|
||||
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 = <TKind extends keyof typeof integrationCreators>(
|
||||
integration: Modify<DbIntegration, { kind: TKind }> & {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
const client = this.getClient();
|
||||
await client.login();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
const client = this.getClient();
|
||||
await client.version();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
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<void> {
|
||||
await this.getClient().editqueue("GroupPause", "", [Number(id)]);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.getClient().resumedownload();
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().editqueue("GroupResume", "", [Number(id)]);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id, progress }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
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<NzbGetClient>(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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
const client = this.getClient();
|
||||
await client.login();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
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<void> {
|
||||
await this.getClient().pauseTorrent(id);
|
||||
}
|
||||
|
||||
public async resumeQueueAsync() {
|
||||
await this.getClient().resumeTorrent("all");
|
||||
}
|
||||
|
||||
public async resumeItemAsync({ id }: DownloadClientItem): Promise<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
//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<DownloadClientJobsAndStatus> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown> {
|
||||
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<unknown>;
|
||||
})
|
||||
.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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -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<void> {
|
||||
await this.getClient().getSession();
|
||||
}
|
||||
|
||||
public async getClientJobsAndStatusAsync(): Promise<DownloadClientJobsAndStatus> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.getClient().resumeTorrent(id);
|
||||
}
|
||||
|
||||
public async deleteItemAsync({ id }: DownloadClientItem, fromDisk: boolean): Promise<void> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<DownloadClientJobsAndStatus>;
|
||||
/** Pauses the client or all of it's items */
|
||||
public abstract pauseQueueAsync(): Promise<void>;
|
||||
/** Pause a single item using it's ID */
|
||||
public abstract pauseItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** Resumes the client or all of it's items */
|
||||
public abstract resumeQueueAsync(): Promise<void>;
|
||||
/** Resume a single item using it's ID */
|
||||
public abstract resumeItemAsync(item: DownloadClientItem): Promise<void>;
|
||||
/** Delete an entry on the client or a file from disk */
|
||||
public abstract deleteItemAsync(item: DownloadClientItem, fromDisk: boolean): Promise<void>;
|
||||
}
|
||||
@@ -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<typeof downloadClientItemSchema>;
|
||||
|
||||
export type ExtendedDownloadClientItem = {
|
||||
integration: Integration;
|
||||
received: number;
|
||||
ratio?: number;
|
||||
actions?: {
|
||||
resume: () => void;
|
||||
pause: () => void;
|
||||
delete: ({ fromDisk }: { fromDisk: boolean }) => void;
|
||||
};
|
||||
} & DownloadClientItem;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
])
|
||||
|
||||
195
packages/integrations/test/nzbget.spec.ts
Normal file
195
packages/integrations/test/nzbget.spec.ts
Normal file
@@ -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");
|
||||
};
|
||||
235
packages/integrations/test/sabnzbd.spec.ts
Normal file
235
packages/integrations/test/sabnzbd.spec.ts
Normal file
@@ -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");
|
||||
};
|
||||
407
packages/integrations/test/volumes/usenet/sabnzbd.ini
Executable file
407
packages/integrations/test/volumes/usenet/sabnzbd.ini
Executable file
@@ -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
|
||||
@@ -0,0 +1,317 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE nzb PUBLIC "-//newzBin//DTD NZB 1.1//EN" "http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd">
|
||||
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672638" subject="reftestnzb_100MB_a82beff8e340 [01/20] - "sometestfile-100MB.part01.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739737" number="1">EkZuHcMrVxSbBbBaSuFgVbMm-1658672638813@nyuu</segment>
|
||||
<segment bytes="739573" number="2">HeTrDnAcEoFtWyJhTdPkNhPe-1658672638816@nyuu</segment>
|
||||
<segment bytes="739672" number="3">OyFbRuRmVxPzXrZoKpWhJsKm-1658672638821@nyuu</segment>
|
||||
<segment bytes="739807" number="4">NwNaDvOgStUoRfVbXmZxKeQf-1658672638822@nyuu</segment>
|
||||
<segment bytes="739558" number="5">DpZgLkTwXvIePlNdDiCcEkXu-1658672638853@nyuu</segment>
|
||||
<segment bytes="739741" number="6">HlTnIiCoXaLbOyOpXyIsMjJo-1658672638855@nyuu</segment>
|
||||
<segment bytes="739665" number="7">YqLsWoYuZnHbYvCjSuZpJdQx-1658672638856@nyuu</segment>
|
||||
<segment bytes="739603" number="8">TgRzBeNrGuQhTxIdLbZgGnNv-1658672638857@nyuu</segment>
|
||||
<segment bytes="739514" number="9">BtUmYfDwAaSdWgRnWjKfRkMl-1658672638862@nyuu</segment>
|
||||
<segment bytes="739612" number="10">EoUoUdSxYgIhVlQrPpMtHzFg-1658672638883@nyuu</segment>
|
||||
<segment bytes="739650" number="11">MeEhBmZsBzSqEtZcFzLqUwMr-1658672639406@nyuu</segment>
|
||||
<segment bytes="739796" number="12">VwBfZmSuHdVuUfJsUnCiKgAl-1658672639428@nyuu</segment>
|
||||
<segment bytes="739593" number="13">IfJyWnIgPhKkFvEmSqQiQzSd-1658672639461@nyuu</segment>
|
||||
<segment bytes="739576" number="14">LiRyAkTyOwRkVzMnJpAzJlQr-1658672639469@nyuu</segment>
|
||||
<segment bytes="464856" number="15">PwMjYlAzKaMbOcWjGrNhLvNc-1658672639479@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672639" subject="reftestnzb_100MB_a82beff8e340 [02/20] - "sometestfile-100MB.part02.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739650" number="1">UtJrLhPwPvHrZjTvUjZrCpKw-1658672639558@nyuu</segment>
|
||||
<segment bytes="739528" number="2">OzChWnChCwAeBsHrBvKvPcGr-1658672639883@nyuu</segment>
|
||||
<segment bytes="739692" number="3">VsLoYaHzQfOmDgNdPnTzPzLx-1658672639939@nyuu</segment>
|
||||
<segment bytes="739648" number="4">FkDiOcGkYxNwOgLrJaWcShKy-1658672640012@nyuu</segment>
|
||||
<segment bytes="739652" number="5">OyGyQxWoHmMxHzPaRdGeMlXz-1658672640035@nyuu</segment>
|
||||
<segment bytes="739643" number="6">NiFsKxNsWjCxXxQfPtNmGyOw-1658672640039@nyuu</segment>
|
||||
<segment bytes="739840" number="7">JqMdEoVhTaRtAdLeYfAeCvRi-1658672640078@nyuu</segment>
|
||||
<segment bytes="739454" number="8">NkOnBwJtHjBoMlUkHjHrNdGo-1658672640324@nyuu</segment>
|
||||
<segment bytes="739842" number="9">QwQnXeMrZjKiJoCbPuSbMjPq-1658672640398@nyuu</segment>
|
||||
<segment bytes="739521" number="10">ZiPvOsXwOjTqIjLnIrKbBdXv-1658672640437@nyuu</segment>
|
||||
<segment bytes="739598" number="11">DwJyDjVzBhKxRyLxIxXqRfHp-1658672640521@nyuu</segment>
|
||||
<segment bytes="739624" number="12">ArRbXcZqRaDhAgRsNmMsGeBh-1658672640545@nyuu</segment>
|
||||
<segment bytes="739597" number="13">UvYkRiCmXfBoIrYxCdEjMtJw-1658672640563@nyuu</segment>
|
||||
<segment bytes="739709" number="14">TqHaSdLuNlWvAaFqZlHqZzJr-1658672640762@nyuu</segment>
|
||||
<segment bytes="464981" number="15">SdNiSzMzVdJkFtTtGtJyEcKi-1658672640859@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672640" subject="reftestnzb_100MB_a82beff8e340 [03/20] - "sometestfile-100MB.part03.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739513" number="1">ZpUwNcWlXaVzJeJcBjXmBqCu-1658672640910@nyuu</segment>
|
||||
<segment bytes="739735" number="2">AtQtJrYjNnYdVbJsXuKvXkSc-1658672640969@nyuu</segment>
|
||||
<segment bytes="739535" number="3">NzBlVfVlKtTxFaIfZxDgAfHa-1658672641009@nyuu</segment>
|
||||
<segment bytes="739683" number="4">JmBgCqStEdWlXqCdWgMtRaKh-1658672641040@nyuu</segment>
|
||||
<segment bytes="739677" number="5">XyLjFiRjRuWmHeVhHmIhIzTg-1658672641200@nyuu</segment>
|
||||
<segment bytes="739566" number="6">IvKxDaYbZkGhMsJiZcXtMhFk-1658672641311@nyuu</segment>
|
||||
<segment bytes="739678" number="7">YuGtYkBnWtRiLdMeUrRxIbId-1658672641377@nyuu</segment>
|
||||
<segment bytes="739671" number="8">GcNoBzAgAhSjJbQkFyKuKbAj-1658672641454@nyuu</segment>
|
||||
<segment bytes="739500" number="9">RlLtFgVnBfWgJpPwTtPoJjLf-1658672641491@nyuu</segment>
|
||||
<segment bytes="739776" number="10">BxLxScLaVfDoNmDwRkMbUxPg-1658672641517@nyuu</segment>
|
||||
<segment bytes="739754" number="11">LhVnUdRnVqFyUiThZyIrNfMt-1658672641649@nyuu</segment>
|
||||
<segment bytes="739497" number="12">VoVyMbIvAuZnWhZoEqVuRpJd-1658672641769@nyuu</segment>
|
||||
<segment bytes="739614" number="13">GnMqIqUjMgVlPsZgAuLlAtGs-1658672641851@nyuu</segment>
|
||||
<segment bytes="739546" number="14">YzPbErKpMvWhVgMiNgJoRmNa-1658672641933@nyuu</segment>
|
||||
<segment bytes="465012" number="15">FaSoKgLtJxPsOsCyPuWgNzEe-1658672641972@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672642" subject="reftestnzb_100MB_a82beff8e340 [04/20] - "sometestfile-100MB.part04.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739583" number="1">PoGtRtIyThRwHgJbSrJhXuNq-1658672642009@nyuu</segment>
|
||||
<segment bytes="739617" number="2">CnRbGgFpMxKxKyKtMmZbSuSa-1658672642085@nyuu</segment>
|
||||
<segment bytes="739565" number="3">NrSeRjXxKaBqWnZaDzMiAmBi-1658672642217@nyuu</segment>
|
||||
<segment bytes="739533" number="4">WwMyBrUwInZkYjUfIsDkLkEr-1658672642300@nyuu</segment>
|
||||
<segment bytes="739641" number="5">DxRtSoVyQrChKqSySdPoDvGn-1658672642384@nyuu</segment>
|
||||
<segment bytes="739722" number="6">SaMlZuQzOiHtNpUcOzRqAkOw-1658672642443@nyuu</segment>
|
||||
<segment bytes="739655" number="7">RuGaHaWfEsAeGpLwLzQpFdOd-1658672642476@nyuu</segment>
|
||||
<segment bytes="739807" number="8">CmNcLvFdZfTjIlXcQiWdHuTe-1658672642524@nyuu</segment>
|
||||
<segment bytes="739636" number="9">UxRnTlCuZjUcIcXhAmYkDdQz-1658672642677@nyuu</segment>
|
||||
<segment bytes="739545" number="10">PvLmSwSzFzJjLoNiVdCpAqKp-1658672642735@nyuu</segment>
|
||||
<segment bytes="739607" number="11">VjVmMvDxOzXvNmOrOwGdBbVg-1658672642826@nyuu</segment>
|
||||
<segment bytes="739816" number="12">ToFnSxDePaBlQcEjViYzSdGo-1658672642903@nyuu</segment>
|
||||
<segment bytes="739556" number="13">LjHkMlYqRaQcBpVkFjCuXrHc-1658672642938@nyuu</segment>
|
||||
<segment bytes="739829" number="14">DpAmYrOyHoXkGkCfZcDiBiIn-1658672642972@nyuu</segment>
|
||||
<segment bytes="465138" number="15">DkGlMfAxEnPcPlAjIbBpNpFg-1658672643110@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672643" subject="reftestnzb_100MB_a82beff8e340 [05/20] - "sometestfile-100MB.part05.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739678" number="1">LkSeRbWaNmKoPaLxRpYcOjWf-1658672643185@nyuu</segment>
|
||||
<segment bytes="739820" number="2">MoUlMsBmZmWtBkFzJtRuYcJy-1658672643272@nyuu</segment>
|
||||
<segment bytes="739746" number="3">QmYmNyEiSmVgSqVaQmEvXuBh-1658672643358@nyuu</segment>
|
||||
<segment bytes="739763" number="4">EfHzOhLcXqKfCwRiKcAmYdCf-1658672643403@nyuu</segment>
|
||||
<segment bytes="739669" number="5">YpBuJtUmPlDvLiPpKlPoTcZg-1658672643425@nyuu</segment>
|
||||
<segment bytes="739657" number="6">PmGyAhWxVtBiOcOhGaIwSxRd-1658672643555@nyuu</segment>
|
||||
<segment bytes="739728" number="7">HaSrHrMhKqHxDdDcYqNkUdEp-1658672643632@nyuu</segment>
|
||||
<segment bytes="739449" number="8">QcDnQmPkMiHuWqIrHsRqWaYu-1658672643725@nyuu</segment>
|
||||
<segment bytes="739583" number="9">SjThZtNpPqLlReTpKlQpTwBk-1658672643799@nyuu</segment>
|
||||
<segment bytes="739761" number="10">KjMnVtRmOgJjYnYeQuLdRkNd-1658672643832@nyuu</segment>
|
||||
<segment bytes="739545" number="11">UeIdYsQtLaKkDqJpFjTrAjSp-1658672643871@nyuu</segment>
|
||||
<segment bytes="739505" number="12">LbVrYoMpQzDbNzXbAdNjEvAo-1658672643996@nyuu</segment>
|
||||
<segment bytes="739686" number="13">QdUwTpUvVhBtDaGvYsTrIuAt-1658672644078@nyuu</segment>
|
||||
<segment bytes="739756" number="14">FtFgNeYsBoLzImYqDbGfSdQf-1658672644160@nyuu</segment>
|
||||
<segment bytes="464954" number="15">OsQiRhKjTyYwAwUaGmUpUlLe-1658672644258@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672644" subject="reftestnzb_100MB_a82beff8e340 [06/20] - "sometestfile-100MB.part06.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739712" number="1">HwJpGfDtJuCiCnLgCuTxAgOe-1658672644335@nyuu</segment>
|
||||
<segment bytes="739540" number="2">DxAdNjPhGcJhNzQeYlJjYaAj-1658672644340@nyuu</segment>
|
||||
<segment bytes="739778" number="3">UsXkQuGsJuQgSpZzAqYqDhPm-1658672644365@nyuu</segment>
|
||||
<segment bytes="739697" number="4">OvDlAlUaWpZuIvImBcInGxZm-1658672644530@nyuu</segment>
|
||||
<segment bytes="739712" number="5">JrGfSbDkHsIrIiZzLpWuJzZj-1658672644599@nyuu</segment>
|
||||
<segment bytes="739687" number="6">TfYyCsKwKgUfYvXlZwGdLuJx-1658672644713@nyuu</segment>
|
||||
<segment bytes="739592" number="7">MgQiXjLvFpEqNdIoMxEkPoJb-1658672644783@nyuu</segment>
|
||||
<segment bytes="739813" number="8">TbBhHaXgToWiTjBkTvPfVjSf-1658672644805@nyuu</segment>
|
||||
<segment bytes="739419" number="9">NpZaHwXwIrZdCeQfBfJuZhVm-1658672644831@nyuu</segment>
|
||||
<segment bytes="739735" number="10">EvDjIqRhNmFzYsTqFxUfLmJo-1658672644945@nyuu</segment>
|
||||
<segment bytes="739727" number="11">YcAnJpKgSmDmTrExKtClJiJw-1658672645044@nyuu</segment>
|
||||
<segment bytes="739698" number="12">UdPfUkYtQqEfBiYsHeJnBoFv-1658672645173@nyuu</segment>
|
||||
<segment bytes="739628" number="13">GjXmLsWnXvQuOhZrXuFaQsWt-1658672645264@nyuu</segment>
|
||||
<segment bytes="739599" number="14">YoEsZaVnNrElAnIoBdZkEsUl-1658672645310@nyuu</segment>
|
||||
<segment bytes="465020" number="15">FiDqTcLyQjSiMoKwNkJcOpKu-1658672645321@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672645" subject="reftestnzb_100MB_a82beff8e340 [07/20] - "sometestfile-100MB.part07.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739612" number="1">BcCnTfTbAqSiUoOkJyMhOpGx-1658672645395@nyuu</segment>
|
||||
<segment bytes="739836" number="2">QsClUcDuHqEcDiTpOdGfDgFs-1658672645483@nyuu</segment>
|
||||
<segment bytes="739582" number="3">EsXdXwNbZnIsFhPxQkKhSaHj-1658672645616@nyuu</segment>
|
||||
<segment bytes="739722" number="4">XuDkCuXkIkJsIjAdKsTkLqWq-1658672645751@nyuu</segment>
|
||||
<segment bytes="739828" number="5">PfEjJjTjHiCsXqDxYiBcUxCw-1658672645777@nyuu</segment>
|
||||
<segment bytes="739674" number="6">IrGgIuRlScWcZwCtDjTrQdKy-1658672645805@nyuu</segment>
|
||||
<segment bytes="739612" number="7">LwVpWrWgUjSuHyLqXoZrMaKb-1658672645859@nyuu</segment>
|
||||
<segment bytes="739769" number="8">VxLjOjYzKeEoGxUgBeIwSfFb-1658672645927@nyuu</segment>
|
||||
<segment bytes="739698" number="9">OaQySkXmXlTnWbSoLkOmExIn-1658672646084@nyuu</segment>
|
||||
<segment bytes="739756" number="10">JmAsMqGcGaVsPiUcRbWqScYh-1658672646171@nyuu</segment>
|
||||
<segment bytes="739526" number="11">EcOcFyCdIaJvOnNjYkMgTyLc-1658672646251@nyuu</segment>
|
||||
<segment bytes="739937" number="12">JdTfZrWbKhYyPpCuOkEuUpWs-1658672646289@nyuu</segment>
|
||||
<segment bytes="739633" number="13">CtZkDeFoUhJyXeTsOxQcPfEg-1658672646332@nyuu</segment>
|
||||
<segment bytes="739722" number="14">ArAjLtAfYtVjRxHwPeTfSsPw-1658672646366@nyuu</segment>
|
||||
<segment bytes="465061" number="15">CmKcCzHnYhEqUxMoOyPwNgUe-1658672646549@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672646" subject="reftestnzb_100MB_a82beff8e340 [08/20] - "sometestfile-100MB.part08.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739536" number="1">ExOtMnHzYeTeXbPrQpMqQcEd-1658672646626@nyuu</segment>
|
||||
<segment bytes="739547" number="2">FvHhUgEeOuOmZuJrOeOdCmBp-1658672646690@nyuu</segment>
|
||||
<segment bytes="739691" number="3">OgIaDbSgOmMjGkNgYzAvOzEa-1658672646751@nyuu</segment>
|
||||
<segment bytes="739690" number="4">LxTeXnRpDpQsMjQxIpRzFfQo-1658672646793@nyuu</segment>
|
||||
<segment bytes="739819" number="5">VcToYzYiJqAwGvCmMiVsGqNj-1658672646834@nyuu</segment>
|
||||
<segment bytes="739598" number="6">XhBxFwNzQdHkIoGcHdMeDeSo-1658672646992@nyuu</segment>
|
||||
<segment bytes="739658" number="7">BmQxAvJfEwEdKzVoMxCoVmIr-1658672647067@nyuu</segment>
|
||||
<segment bytes="739732" number="8">PxYzIiRmDrSdFmKaSfTtQrWp-1658672647129@nyuu</segment>
|
||||
<segment bytes="739867" number="9">PnSpIsPeHgAfThOjXyOpNlCo-1658672647192@nyuu</segment>
|
||||
<segment bytes="739740" number="10">YfHjYuKnUvRlBnKdDqOoGnKo-1658672647224@nyuu</segment>
|
||||
<segment bytes="739780" number="11">WpPkPhPeXsBiMkAkUcEcZuQg-1658672647285@nyuu</segment>
|
||||
<segment bytes="739649" number="12">SlOeJlNtDjHqTuEkAeNxMdDk-1658672647439@nyuu</segment>
|
||||
<segment bytes="739899" number="13">AdCgCfRmEvZkRzXgCoLrHfGa-1658672647508@nyuu</segment>
|
||||
<segment bytes="739666" number="14">JrQhNuXmRiLjRaBvNlBzMgAd-1658672647568@nyuu</segment>
|
||||
<segment bytes="464911" number="15">XiYcGuBtZdChJfIeKeYwAsHy-1658672647654@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672647" subject="reftestnzb_100MB_a82beff8e340 [09/20] - "sometestfile-100MB.part09.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739437" number="1">YnIxYaQuEcKvMpSyYnEeAvVn-1658672647688@nyuu</segment>
|
||||
<segment bytes="739623" number="2">XrFdJwZqRcRtHnIcDnEzCoMc-1658672647743@nyuu</segment>
|
||||
<segment bytes="739486" number="3">WfRuRmRrPwGgKwZhPaAmNpOn-1658672647970@nyuu</segment>
|
||||
<segment bytes="739521" number="4">PkCrNtOrEvNvRoPaDuAuGqSf-1658672648003@nyuu</segment>
|
||||
<segment bytes="739505" number="5">KsFvGlAdBjRfTlZhVxEuWxJm-1658672648029@nyuu</segment>
|
||||
<segment bytes="739627" number="6">ZnIxXyHkDpGpBkLlPkQnHwWt-1658672648104@nyuu</segment>
|
||||
<segment bytes="739623" number="7">RhVuUwDpCoRiUoNzUpOpFrWp-1658672648150@nyuu</segment>
|
||||
<segment bytes="739569" number="8">JpYiPxYmAaClCuXtYwLcTkHb-1658672648199@nyuu</segment>
|
||||
<segment bytes="739753" number="9">GiSuQeEyMkMeVaJmReDyKgVt-1658672648484@nyuu</segment>
|
||||
<segment bytes="739515" number="10">LrFxMoDtJaNcEiLgDxQoFgIq-1658672648486@nyuu</segment>
|
||||
<segment bytes="739602" number="11">BfUcDaUxLsNrZzAvBrLcRdWw-1658672648524@nyuu</segment>
|
||||
<segment bytes="739622" number="12">MgHqJlXjFsEmGtCuCvTrMfDl-1658672648565@nyuu</segment>
|
||||
<segment bytes="739802" number="13">DgZiJzKyBoGaWvXrRcRfPbZy-1658672648642@nyuu</segment>
|
||||
<segment bytes="739798" number="14">SvAwVvXyWaFgPkAwAxLmAoVe-1658672648661@nyuu</segment>
|
||||
<segment bytes="464943" number="15">UwZyZtBxBlZdQnIbMkAmFrUx-1658672648994@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [11/20] - "sometestfile-100MB.part11.rar" yEnc (1/1) 1745">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="1965" number="1">MzGbXzBiDkQkCkPjHgRfYgSt-1658672650132@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672649" subject="reftestnzb_100MB_a82beff8e340 [10/20] - "sometestfile-100MB.part10.rar" yEnc (1/15) 10485760">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739508" number="1">QbGaVgPmAxUeYtGfRrNaCgPi-1658672649037@nyuu</segment>
|
||||
<segment bytes="739728" number="2">TlYaEwZcLkMxXiVlWnXbBhCo-1658672649043@nyuu</segment>
|
||||
<segment bytes="739729" number="3">JvFuObAaRgTpRdAaNsBnUjSf-1658672649046@nyuu</segment>
|
||||
<segment bytes="739717" number="4">ViJqMxYcZuCzRiXqZqPyXhVl-1658672649105@nyuu</segment>
|
||||
<segment bytes="739773" number="5">QaYyAkGsSmRwGwWlYwOcIdCh-1658672649138@nyuu</segment>
|
||||
<segment bytes="739801" number="6">KdLcItOyTuDfZlFvDgFoGjLx-1658672649505@nyuu</segment>
|
||||
<segment bytes="739517" number="7">DwEiPdQdSdKbYjQzSpCtNnBp-1658672649527@nyuu</segment>
|
||||
<segment bytes="739823" number="8">JjZfPzJoYrSnSqOzLfQdLaJe-1658672649559@nyuu</segment>
|
||||
<segment bytes="739682" number="9">JpAdAoOiWbLlElNnXyZqUrZk-1658672649591@nyuu</segment>
|
||||
<segment bytes="739581" number="10">KcXsPhOqSmVlImNiAaBxOeDg-1658672649593@nyuu</segment>
|
||||
<segment bytes="739466" number="11">OcKeQsZvSvCzZzGkSyYaIwLe-1658672649621@nyuu</segment>
|
||||
<segment bytes="739695" number="12">TpSgWsQbCxSvRhCpNeVxXpQp-1658672650017@nyuu</segment>
|
||||
<segment bytes="739642" number="13">FgLlWiOgZsXwZbDiUfRlUbAh-1658672650037@nyuu</segment>
|
||||
<segment bytes="739797" number="14">DpWgTfSaHsEyDoRwKmMrHlNg-1658672650077@nyuu</segment>
|
||||
<segment bytes="464958" number="15">ApWcZeAvJfEfArBnTqKaAlTi-1658672650131@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [12/20] - "sometestfile-100MB.par2" yEnc (1/1) 42740">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="44186" number="1">GiCuNqThWxXhQtBdIpCuSpTu-1658672650163@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [13/20] - "sometestfile-100MB.vol000+001.par2" yEnc (1/1) 95504">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="98613" number="1">XwSuKgDcYgYbThUtGbFnYlMr-1658672650478@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [14/20] - "sometestfile-100MB.vol001+002.par2" yEnc (1/1) 148268">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="153068" number="1">XtIpShElZfGwAxEdWcWmMoSg-1658672650533@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [15/20] - "sometestfile-100MB.vol003+004.par2" yEnc (1/1) 296420">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="305933" number="1">YjBcYcXxFoSmTdKrXbQaVcEc-1658672650555@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [16/20] - "sometestfile-100MB.vol007+008.par2" yEnc (1/1) 550100">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="567518" number="1">YrBmEjHwHgWrGpWwWvHnZsNr-1658672650628@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [17/20] - "sometestfile-100MB.vol015+016.par2" yEnc (1/2) 1014836">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739515" number="1">HmLkJbFaPwGnDeHoAsUeWvOx-1658672650641@nyuu</segment>
|
||||
<segment bytes="307638" number="2">BgMgXvTfPmQpMeIxMrVbSbWb-1658672650648@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672650" subject="reftestnzb_100MB_a82beff8e340 [18/20] - "sometestfile-100MB.vol031+032.par2" yEnc (1/3) 1901684">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739626" number="1">InAlBjYtHfRoZbUcLbLjVwGg-1658672650925@nyuu</segment>
|
||||
<segment bytes="739341" number="2">UeLxHaYaYrGcWoApMiIeUcFc-1658672650940@nyuu</segment>
|
||||
<segment bytes="482988" number="3">TmTyRsQpWjLvJoZtYvDxKfDk-1658672650960@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672651" subject="reftestnzb_100MB_a82beff8e340 [19/20] - "sometestfile-100MB.vol063+064.par2" yEnc (1/6) 3632756">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739390" number="1">AoFjKyMxTlDpZtDzHyJtFaVt-1658672651017@nyuu</segment>
|
||||
<segment bytes="739605" number="2">WbUyNnEyReLnSdNwBsVqVfVc-1658672651029@nyuu</segment>
|
||||
<segment bytes="739539" number="3">FaXfJfTqWuIiMvTlGdOfGgKi-1658672651035@nyuu</segment>
|
||||
<segment bytes="739543" number="4">NeEnAwTqVeRoJuStBvPhSsCf-1658672651324@nyuu</segment>
|
||||
<segment bytes="739399" number="5">FeFfMtWjQyDkIcPaPnFnTvZl-1658672651372@nyuu</segment>
|
||||
<segment bytes="50461" number="6">JxFgMzBwLqVoRcPuJzHoSgFy-1658672651406@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
<file poster="blablamannetje <blabla@example.com>" date="1658672651" subject="reftestnzb_100MB_a82beff8e340 [20/20] - "sometestfile-100MB.vol127+072.par2" yEnc (1/6) 4054868">
|
||||
<groups>
|
||||
<group>alt.binaries.test</group>
|
||||
</groups>
|
||||
<segments>
|
||||
<segment bytes="739529" number="1">ZrZzDkZqMlGxTlXsOxZzWkFy-1658672651436@nyuu</segment>
|
||||
<segment bytes="739638" number="2">EkIfIsZtKbFcHyLtEiOvCgUe-1658672651500@nyuu</segment>
|
||||
<segment bytes="739479" number="3">FdAlCsPqQgToRlEcZxCzHhFu-1658672651528@nyuu</segment>
|
||||
<segment bytes="739655" number="4">OnYrJuAaClWaDjEdFmYoDaKt-1658672651727@nyuu</segment>
|
||||
<segment bytes="739624" number="5">TsJbMqVtYcIaGqEvShTyEhWf-1658672651793@nyuu</segment>
|
||||
<segment bytes="485771" number="6">UbNvVcQoDxAfCiPsEqFfGkDu-1658672651860@nyuu</segment>
|
||||
</segments>
|
||||
</file>
|
||||
</nzb>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<MultiSelect
|
||||
label={t("label")}
|
||||
label={tWidget("label")}
|
||||
data={options.options.map((option) =>
|
||||
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}`)}
|
||||
/>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -93,6 +93,11 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
|
||||
} & {
|
||||
boardId: string | undefined; // undefined when in preview mode
|
||||
isEditMode: boolean;
|
||||
setOptions: ({
|
||||
newOptions,
|
||||
}: {
|
||||
newOptions: Partial<inferOptionsFromDefinition<WidgetOptionsRecordOf<TKind>>>;
|
||||
}) => void;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
922
packages/widgets/src/downloads/component.tsx
Normal file
922
packages/widgets/src/downloads/component.tsx
Normal file
@@ -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<keyof ExtendedDownloadClientItem, number> = {
|
||||
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<number>(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<ExtendedDownloadClientItem[]>(
|
||||
() =>
|
||||
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<ExtendedClientStatus[]>(
|
||||
() =>
|
||||
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<keyof ExtendedDownloadClientItem, boolean>;
|
||||
|
||||
//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<ExtendedDownloadClientItem> => {
|
||||
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 ? <Text fw={700}>{t(`items.${key}.columnTitle`)}</Text> : ""),
|
||||
};
|
||||
};
|
||||
|
||||
//Make columns and cell elements, Memoized to data with deps on data and EditMode
|
||||
const columns = useMemo<MRT_ColumnDef<ExtendedDownloadClientItem>[]>(
|
||||
() => [
|
||||
{
|
||||
...columnsDefBase({ key: "actions", showHeader: false, align: "center" }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell, row }) => {
|
||||
const actions = cell.getValue<ExtendedDownloadClientItem["actions"]>();
|
||||
const pausedAction = row.original.state === "paused" ? "resume" : "pause";
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
return actions ? (
|
||||
<Group wrap="nowrap" gap="var(--space-size)">
|
||||
<Tooltip label={t(`actions.item.${pausedAction}`)}>
|
||||
<ActionIcon variant="light" radius={999} onClick={actions[pausedAction]} size="var(--button-size)">
|
||||
{pausedAction === "resume" ? (
|
||||
<IconPlayerPlay style={actionIconIconStyle} />
|
||||
) : (
|
||||
<IconPlayerPause style={actionIconIconStyle} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("actions.item.delete.title")}>
|
||||
<ActionIcon color="red" radius={999} onClick={open} size="var(--button-size)">
|
||||
<IconTrash style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Modal opened={opened} onClose={close} title={t("actions.item.delete.modalTitle")} size="auto" centered>
|
||||
<Group>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
close();
|
||||
actions.delete({ fromDisk: false });
|
||||
}}
|
||||
>
|
||||
{t("actions.item.delete.entry")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
close();
|
||||
actions.delete({ fromDisk: true });
|
||||
}}
|
||||
leftSection={<IconAlertTriangle />}
|
||||
>
|
||||
{t("actions.item.delete.entryAndFiles")}
|
||||
</Button>
|
||||
<Button color="green" onClick={close}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Group>
|
||||
) : (
|
||||
<ActionIcon radius={999} disabled size="var(--button-size)">
|
||||
<IconX style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "added", showHeader: true, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const added = cell.getValue<ExtendedDownloadClientItem["added"]>();
|
||||
return <Text>{added !== undefined ? dayjs(added).fromNow() : "unknown"}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "category", showHeader: false, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const category = cell.getValue<ExtendedDownloadClientItem["category"]>();
|
||||
return (
|
||||
category !== undefined && (
|
||||
<Tooltip label={category}>
|
||||
<IconInfoCircle style={standardIconStyle} />
|
||||
</Tooltip>
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "downSpeed", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const downSpeed = cell.getValue<ExtendedDownloadClientItem["downSpeed"]>();
|
||||
return downSpeed && <Text>{humanFileSize(downSpeed, "/s")}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "id", showHeader: false, align: "center" }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => {
|
||||
const id = cell.getValue<ExtendedDownloadClientItem["id"]>();
|
||||
return (
|
||||
<Tooltip label={id}>
|
||||
<IconCirclesRelation style={standardIconStyle} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "index", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const index = cell.getValue<ExtendedDownloadClientItem["index"]>();
|
||||
return <Text>{index}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "integration", showHeader: false, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const integration = cell.getValue<ExtendedDownloadClientItem["integration"]>();
|
||||
return (
|
||||
<Tooltip label={integration.name}>
|
||||
<Avatar size="var(--image-size)" radius={0} src={getIconUrl(integration.kind)} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "name", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const name = cell.getValue<ExtendedDownloadClientItem["name"]>();
|
||||
return (
|
||||
<Text lineClamp={1} style={{ wordBreak: "break-all" }}>
|
||||
{name}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "progress", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell, row }) => {
|
||||
const progress = cell.getValue<ExtendedDownloadClientItem["progress"]>();
|
||||
return (
|
||||
<Stack w="100%" align="center" gap="var(--space-size)">
|
||||
<Text lh="var(--text-fz)">
|
||||
{new Intl.NumberFormat("en", { style: "percent", notation: "compact", unitDisplay: "narrow" }).format(
|
||||
progress,
|
||||
)}
|
||||
</Text>
|
||||
<Progress
|
||||
h="calc(var(--ratio-width)*0.25)"
|
||||
w="100%"
|
||||
value={progress * 100}
|
||||
color={row.original.state === "paused" ? "yellow" : progress === 1 ? "green" : "blue"}
|
||||
radius={999}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "ratio", showHeader: true, align: "center" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const ratio = cell.getValue<ExtendedDownloadClientItem["ratio"]>();
|
||||
return ratio !== undefined && <Text>{ratio.toFixed(ratio >= 100 ? 0 : ratio >= 10 ? 1 : 2)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "received", showHeader: true, align: "right" }),
|
||||
Cell: ({ cell }) => {
|
||||
const received = cell.getValue<ExtendedDownloadClientItem["received"]>();
|
||||
return <Text>{humanFileSize(received)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "sent", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const sent = cell.getValue<ExtendedDownloadClientItem["sent"]>();
|
||||
return sent && <Text>{humanFileSize(sent)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "size", showHeader: true, align: "right" }),
|
||||
Cell: ({ cell }) => {
|
||||
const size = cell.getValue<ExtendedDownloadClientItem["size"]>();
|
||||
return <Text>{humanFileSize(size)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "state", showHeader: true }),
|
||||
enableSorting: false,
|
||||
Cell: ({ cell }) => {
|
||||
const state = cell.getValue<ExtendedDownloadClientItem["state"]>();
|
||||
return <Text>{t(`states.${state}`)}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "time", showHeader: true, align: "center" }),
|
||||
Cell: ({ cell }) => {
|
||||
const time = cell.getValue<ExtendedDownloadClientItem["time"]>();
|
||||
return time === 0 ? <IconInfinity style={standardIconStyle} /> : <Text>{dayjs().add(time).fromNow()}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "type", showHeader: true }),
|
||||
Cell: ({ cell }) => {
|
||||
const type = cell.getValue<ExtendedDownloadClientItem["type"]>();
|
||||
return <Text>{type}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
...columnsDefBase({ key: "upSpeed", showHeader: true, align: "right" }),
|
||||
sortUndefined: "last",
|
||||
Cell: ({ cell }) => {
|
||||
const upSpeed = cell.getValue<ExtendedDownloadClientItem["upSpeed"]>();
|
||||
return upSpeed && <Text>{humanFileSize(upSpeed, "/s")}</Text>;
|
||||
},
|
||||
},
|
||||
],
|
||||
[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<keyof ExtendedDownloadClientItem, boolean>,
|
||||
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 (
|
||||
<Center h="100%">
|
||||
<Text fz="7.5cqw">{t("errors.noColumns")}</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
//The actual widget
|
||||
return (
|
||||
<Stack gap={0} h="100%" display="flex" style={baseStyle}>
|
||||
<MantineReactTable table={table} />
|
||||
<Group
|
||||
h="var(--ratio-width)"
|
||||
px="var(--space-size)"
|
||||
justify={integrationTypes.includes("torrent") ? "space-between" : "end"}
|
||||
style={{
|
||||
flexDirection: isLangRtl ? "row-reverse" : "row",
|
||||
borderTop: "0.0625rem solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{integrationTypes.includes("torrent") && (
|
||||
<Group pt="var(--space-size)" style={{ flexDirection: isLangRtl ? "row-reverse" : "row" }}>
|
||||
<Text>{tCommon("rtl", { value: t("globalRatio"), symbol: tCommon("symbols.colon") })}</Text>
|
||||
<Text>{(globalTraffic.up / globalTraffic.down).toFixed(2)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
<ClientsControl clients={clients} style={editStyle} />
|
||||
</Group>
|
||||
<ItemInfoModal items={data} currentIndex={clickedIndex} opened={opened} onClose={close} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemInfoModalProps {
|
||||
items: ExtendedDownloadClientItem[];
|
||||
currentIndex: number;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ItemInfoModal = ({ items, currentIndex, opened, onClose }: ItemInfoModalProps) => {
|
||||
const item = useMemo<ExtendedDownloadClientItem | undefined>(
|
||||
() => 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 (
|
||||
<Modal opened={opened} onClose={onClose} centered title={item?.id ?? "ERROR"} size="auto">
|
||||
{item === undefined ? (
|
||||
<Center>{"No item found"}</Center>
|
||||
) : (
|
||||
<Stack align="center">
|
||||
<Title>{item.name}</Title>
|
||||
<Group>
|
||||
<Avatar src={getIconUrl(item.integration.kind)} />
|
||||
<Text>{`${item.integration.name} (${item.integration.kind})`}</Text>
|
||||
</Group>
|
||||
<NormalizedLine itemKey="index" values={item.index} />
|
||||
<NormalizedLine itemKey="type" values={item.type} />
|
||||
<NormalizedLine itemKey="state" values={t(item.state)} />
|
||||
<NormalizedLine
|
||||
itemKey="upSpeed"
|
||||
values={item.upSpeed === undefined ? undefined : humanFileSize(item.upSpeed, "/s")}
|
||||
/>
|
||||
<NormalizedLine
|
||||
itemKey="downSpeed"
|
||||
values={item.downSpeed === undefined ? undefined : humanFileSize(item.downSpeed, "/s")}
|
||||
/>
|
||||
<NormalizedLine itemKey="sent" values={item.sent === undefined ? undefined : humanFileSize(item.sent)} />
|
||||
<NormalizedLine itemKey="received" values={humanFileSize(item.received)} />
|
||||
<NormalizedLine itemKey="size" values={humanFileSize(item.size)} />
|
||||
<NormalizedLine
|
||||
itemKey="progress"
|
||||
values={new Intl.NumberFormat("en", {
|
||||
style: "percent",
|
||||
notation: "compact",
|
||||
unitDisplay: "narrow",
|
||||
}).format(item.progress)}
|
||||
/>
|
||||
<NormalizedLine itemKey="ratio" values={item.ratio} />
|
||||
<NormalizedLine itemKey="added" values={item.added === undefined ? "unknown" : dayjs(item.added).format()} />
|
||||
<NormalizedLine itemKey="time" values={item.time !== 0 ? dayjs().add(item.time).format() : "∞"} />
|
||||
<NormalizedLine itemKey="category" values={item.category} />
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const NormalizedLine = ({
|
||||
itemKey,
|
||||
values,
|
||||
}: {
|
||||
itemKey: Exclude<keyof ExtendedDownloadClientItem, "integration" | "actions" | "name" | "id">;
|
||||
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 (
|
||||
<Group
|
||||
w="100%"
|
||||
display="flex"
|
||||
style={{ flexDirection: isLangRtl ? "row-reverse" : "row" }}
|
||||
align="top"
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text>{keyString}</Text>
|
||||
{Array.isArray(values) ? (
|
||||
<Stack>
|
||||
{values.map((value) => (
|
||||
<Text key={value}>{value}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{values}</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Group gap="var(--space-size)" style={style}>
|
||||
<AvatarGroup spacing="calc(var(--space-size)*2)">
|
||||
{clients.map((client) => (
|
||||
<Avatar
|
||||
key={client.integration.id}
|
||||
src={getIconUrl(client.integration.kind)}
|
||||
size="var(--image-size)"
|
||||
bd={client.status ? 0 : "calc(var(--space-size)*0.5) solid var(--mantine-color-red-filled)"}
|
||||
/>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
{someInteract && (
|
||||
<Tooltip label={t("actions.clients.resume")}>
|
||||
<ActionIcon
|
||||
size="var(--button-size)"
|
||||
radius={999}
|
||||
disabled={integrationsStatuses.paused.length === 0}
|
||||
variant="light"
|
||||
onClick={() => mutateResumeQueue({ integrationIds: integrationsStatuses.paused })}
|
||||
>
|
||||
<IconPlayerPlay style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
radius={999}
|
||||
h="var(--button-size)"
|
||||
px="calc(var(--space-size)*2)"
|
||||
fw="500"
|
||||
onClick={open}
|
||||
>
|
||||
{totalSpeed}
|
||||
</Button>
|
||||
{someInteract && (
|
||||
<Tooltip label={t("actions.clients.pause")}>
|
||||
<ActionIcon
|
||||
size="var(--button-size)"
|
||||
radius={999}
|
||||
disabled={integrationsStatuses.active.length === 0}
|
||||
variant="light"
|
||||
onClick={() => mutatePauseQueue({ integrationIds: integrationsStatuses.active })}
|
||||
>
|
||||
<IconPlayerPause style={actionIconIconStyle} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Modal opened={opened} onClose={close} title={t("actions.clients.modalTitle")} centered size="auto">
|
||||
<Stack gap="10px">
|
||||
{clients.map((client) => (
|
||||
<Stack key={client.integration.id} gap="10px">
|
||||
<Divider />
|
||||
<Group wrap="nowrap" w="100%">
|
||||
<Paper withBorder radius={999}>
|
||||
<Group gap={5} pl={10} pr={15} fz={16} w={275} justify="space-between" wrap="nowrap">
|
||||
<Avatar radius={0} src={getIconUrl(client.integration.kind)} />
|
||||
{client.status ? (
|
||||
<Tooltip disabled={client.status.ratio === undefined} label={client.status.ratio?.toFixed(2)}>
|
||||
<Stack gap={0} pt={5} h={60} justify="center" flex={1}>
|
||||
{client.status.rates.up !== undefined ? (
|
||||
<Group display="flex" justify="center" c="green" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↑ ${humanFileSize(client.status.rates.up, "/s")}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(client.status.totalUp ?? 0)}
|
||||
</Text>
|
||||
</Group>
|
||||
) : undefined}
|
||||
<Group display="flex" justify="center" c="blue" w="100%" gap={5}>
|
||||
<Text flex={1} ta="right">
|
||||
{`↓ ${humanFileSize(client.status.rates.down, "/s")}`}
|
||||
</Text>
|
||||
<Text>{"-"}</Text>
|
||||
<Text flex={1} ta="left">
|
||||
{humanFileSize(Math.floor(client.status.totalDown ?? 0))}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text c="red" ta="center">
|
||||
{t("errors.noCommunications")}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
<Text lineClamp={1} fz={22}>
|
||||
{client.integration.name}
|
||||
</Text>
|
||||
<Space flex={1} />
|
||||
{client.status && client.interact ? (
|
||||
<Tooltip label={t(`actions.client.${client.status.paused ? "resume" : "pause"}`)}>
|
||||
<ActionIcon
|
||||
radius={999}
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
(client.status?.paused ? mutateResumeQueue : mutatePauseQueue)({
|
||||
integrationIds: [client.integration.id],
|
||||
});
|
||||
}}
|
||||
>
|
||||
{client.status.paused ? <IconPlayerPlay /> : <IconPlayerPause />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<ActionIcon radius={999} variant="light" size="lg" disabled>
|
||||
<IconX />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
110
packages/widgets/src/downloads/index.ts
Normal file
110
packages/widgets/src/downloads/index.ts
Normal file
@@ -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<typeof columnsList, (typeof sortingExclusion)[number]>;
|
||||
|
||||
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"));
|
||||
21
packages/widgets/src/downloads/serverData.ts
Normal file
21
packages/widgets/src/downloads/serverData.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -47,9 +47,9 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
|
||||
z.object({
|
||||
options: z.object(
|
||||
objectEntries(widgetImports[innerProps.kind].definition.options).reduce(
|
||||
(acc, [key, value]: [string, { validate?: z.ZodType<unknown> }]) => {
|
||||
(acc, [key, value]: [string, { type: string; validate?: z.ZodType<unknown> }]) => {
|
||||
if (value.validate) {
|
||||
acc[key] = value.validate;
|
||||
acc[key] = value.type === "multiText" ? z.array(value.validate).optional() : value.validate;
|
||||
}
|
||||
|
||||
return acc;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
36
packages/widgets/src/widgets-common.css
Normal file
36
packages/widgets/src/widgets-common.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
438
pnpm-lock.yaml
generated
438
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user