mirror of
https://github.com/ajnart/homarr.git
synced 2026-03-01 18:00:55 +01:00
chore(release): automatic release v0.1.0
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",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/modals": "workspace:^0.1.0",
|
||||
"@homarr/notifications": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@homarr/spotlight": "workspace:^0.1.0",
|
||||
"@homarr/translation": "workspace:^0.1.0",
|
||||
@@ -41,10 +42,10 @@
|
||||
"@mantine/tiptap": "^7.12.2",
|
||||
"@million/lint": "1.0.0-rc.84",
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"@tabler/icons-react": "^3.14.0",
|
||||
"@tanstack/react-query": "^5.55.0",
|
||||
"@tanstack/react-query-devtools": "^5.55.0",
|
||||
"@tanstack/react-query-next-experimental": "5.55.0",
|
||||
"@tabler/icons-react": "^3.16.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@tanstack/react-query-devtools": "^5.56.2",
|
||||
"@tanstack/react-query-next-experimental": "5.56.2",
|
||||
"@trpc/client": "next",
|
||||
"@trpc/next": "next",
|
||||
"@trpc/react-query": "next",
|
||||
@@ -52,7 +53,7 @@
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"chroma-js": "^3.0.0",
|
||||
"chroma-js": "^3.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.5",
|
||||
@@ -60,7 +61,7 @@
|
||||
"glob": "^11.0.0",
|
||||
"jotai": "^2.9.3",
|
||||
"mantine-react-table": "2.0.0-beta.6",
|
||||
"next": "^14.2.8",
|
||||
"next": "^14.2.11",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -82,10 +83,10 @@
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.9.1",
|
||||
"concurrently": "^9.0.1",
|
||||
"eslint": "^9.10.0",
|
||||
"node-loader": "^2.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
|
||||
import { createWSClient, loggerLink, unstable_httpBatchStreamLink, wsLink } from "@trpc/client";
|
||||
import {
|
||||
createWSClient,
|
||||
httpLink,
|
||||
isNonJsonSerializable,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
unstable_httpBatchStreamLink,
|
||||
wsLink,
|
||||
} from "@trpc/client";
|
||||
import superjson from "superjson";
|
||||
|
||||
import type { AppRouter } from "@homarr/api";
|
||||
@@ -34,18 +42,29 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
enabled: (opts) =>
|
||||
process.env.NODE_ENV === "development" || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
(args) => {
|
||||
return ({ op, next }) => {
|
||||
console.log("op", op.type, op.input, op.path, op.id);
|
||||
if (op.type === "subscription") {
|
||||
const link = wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
});
|
||||
return link(args)({ op, next });
|
||||
}
|
||||
|
||||
return unstable_httpBatchStreamLink({
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
true: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
}),
|
||||
false: splitLink({
|
||||
condition: ({ input }) => isNonJsonSerializable(input),
|
||||
true: httpLink({
|
||||
/**
|
||||
* We don't want to transform the data here as we want to use form data
|
||||
*/
|
||||
transformer: {
|
||||
serialize(object: unknown) {
|
||||
return object;
|
||||
},
|
||||
deserialize(data: unknown) {
|
||||
return data;
|
||||
},
|
||||
},
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
false: unstable_httpBatchStreamLink({
|
||||
transformer: superjson,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers() {
|
||||
@@ -53,9 +72,9 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
})(args)({ op, next });
|
||||
};
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ModalProvider } from "@homarr/modals";
|
||||
import { Notifications } from "@homarr/notifications";
|
||||
|
||||
import { Analytics } from "~/components/layout/analytics";
|
||||
import { SearchEngineOptimization } from "~/components/layout/search-engine-optimization";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||
@@ -70,6 +71,7 @@ export default async function Layout(props: { children: React.ReactNode; params:
|
||||
<html lang="en" data-mantine-color-scheme={colorScheme} suppressHydrationWarning>
|
||||
<head>
|
||||
<Analytics />
|
||||
<SearchEngineOptimization />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<StackedProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -17,16 +18,15 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLanguage, IconLibrary, IconUsers } from "@tabler/icons-react";
|
||||
import { setStaticParamsLocale } from "next-international/server";
|
||||
|
||||
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { getPackageAttributesAsync } from "~/versions/package-reader";
|
||||
import contributorsData from "../../../../../../../static-data/contributors.json";
|
||||
import translatorsData from "../../../../../../../static-data/translators.json";
|
||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -37,16 +37,26 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
const getHost = () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
setStaticParamsLocale(locale);
|
||||
return headers().get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${getHost()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const attributes = await getPackageAttributesAsync();
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof githubContributorsJson;
|
||||
|
||||
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof crowdinContributorsJson;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DynamicBreadcrumb />
|
||||
@@ -70,14 +80,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
<Text>{t("accordion.contributors.title")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("accordion.contributors.subtitle", {
|
||||
count: contributorsData.length,
|
||||
count: githubContributors.length,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Flex wrap="wrap" gap="xs">
|
||||
{contributorsData.map((contributor) => (
|
||||
{githubContributors.map((contributor) => (
|
||||
<GenericContributorLinkCard
|
||||
key={contributor.login}
|
||||
link={`https://github.com/${contributor.login}`}
|
||||
@@ -94,14 +104,14 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
|
||||
<Text>{t("accordion.translators.title")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("accordion.translators.subtitle", {
|
||||
count: translatorsData.length,
|
||||
count: crowdinContributors.length,
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<Flex wrap="wrap" gap="xs">
|
||||
{translatorsData.map((translator) => (
|
||||
{crowdinContributors.map((translator) => (
|
||||
<GenericContributorLinkCard
|
||||
key={translator.username}
|
||||
link={`https://crowdin.com/profile/${translator.username}`}
|
||||
@@ -164,9 +174,3 @@ const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLin
|
||||
</AspectRatio>
|
||||
);
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getStaticParams();
|
||||
}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { IconCategoryPlus } from "@tabler/icons-react";
|
||||
import { Affix, Button, Group, Menu } from "@mantine/core";
|
||||
import { IconCategoryPlus, IconChevronDown, IconFileImport } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { BetaBadge } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
import { AddBoardModal } from "~/components/manage/boards/add-board-modal";
|
||||
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
import { ImportBoardModal } from "~/components/manage/boards/import-board-modal";
|
||||
|
||||
interface CreateBoardButtonProps {
|
||||
boardNames: string[];
|
||||
@@ -17,7 +19,8 @@ interface CreateBoardButtonProps {
|
||||
|
||||
export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openAddModal } = useModalAction(AddBoardModal);
|
||||
const { openModal: openImportModal } = useModalAction(ImportBoardModal);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.createBoard.useMutation({
|
||||
onSettled: async () => {
|
||||
@@ -25,8 +28,8 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
openModal({
|
||||
const onCreateClick = useCallback(() => {
|
||||
openAddModal({
|
||||
onSuccess: async (values) => {
|
||||
await mutateAsync({
|
||||
name: values.name,
|
||||
@@ -36,11 +39,41 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
|
||||
},
|
||||
boardNames,
|
||||
});
|
||||
}, [mutateAsync, boardNames, openModal]);
|
||||
}, [mutateAsync, boardNames, openAddModal]);
|
||||
|
||||
const onImportClick = useCallback(() => {
|
||||
openImportModal({ boardNames });
|
||||
}, [openImportModal, boardNames]);
|
||||
|
||||
const buttonGroupContent = (
|
||||
<>
|
||||
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onCreateClick} loading={isPending}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</Button>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Button px="xs" ms={1}>
|
||||
<IconChevronDown size="1rem" />
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={onImportClick} leftSection={<IconFileImport size="1rem" />}>
|
||||
<Group>
|
||||
{t("board.action.oldImport.label")}
|
||||
<BetaBadge size="xs" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobileAffixButton leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
|
||||
{t("management.page.board.action.new.label")}
|
||||
</MobileAffixButton>
|
||||
<>
|
||||
<Button.Group visibleFrom="md">{buttonGroupContent}</Button.Group>
|
||||
<Affix hiddenFrom="md" position={{ bottom: 20, right: 20 }}>
|
||||
<Button.Group>{buttonGroupContent}</Button.Group>
|
||||
</Affix>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import React from "react";
|
||||
import type { MantineSpacing } from "@mantine/core";
|
||||
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface AnalyticsSettingsProps {
|
||||
@@ -62,6 +60,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
ms="xl"
|
||||
title={t("integrationData.title")}
|
||||
text={t("integrationData.text")}
|
||||
disabled={!form.values.enableGeneral}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
@@ -69,6 +68,7 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
ms="xl"
|
||||
title={t("widgetData.title")}
|
||||
text={t("widgetData.text")}
|
||||
disabled={!form.values.enableGeneral}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
@@ -76,45 +76,10 @@ export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
ms="xl"
|
||||
title={t("usersData.title")}
|
||||
text={t("usersData.text")}
|
||||
disabled={!form.values.enableGeneral}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const SwitchSetting = ({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
text,
|
||||
formKey,
|
||||
}: {
|
||||
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
|
||||
formKey: keyof typeof defaultServerSettings.analytics;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
}) => {
|
||||
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
form.setFieldValue(formKey, !form.values[formKey]);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold">{title}</Text>
|
||||
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface CrawlingAndIndexingSettingsProps {
|
||||
initialData: typeof defaultServerSettings.crawlingAndIndexing;
|
||||
}
|
||||
|
||||
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void mutateAsync({
|
||||
settingsKey: "crawlingAndIndexing",
|
||||
value: updatedValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathActionAsync("/manage/settings");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title order={2}>{t("title")}</Title>
|
||||
|
||||
<Card pos="relative" withBorder>
|
||||
<Text c={"dimmed"} mb={"lg"}>
|
||||
{t("warning")}
|
||||
</Text>
|
||||
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
|
||||
<Stack>
|
||||
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
|
||||
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noTranslate"
|
||||
title={t("noTranslate.title")}
|
||||
text={t("noTranslate.text")}
|
||||
/>
|
||||
<SwitchSetting
|
||||
form={form}
|
||||
formKey="noSiteLinksSearchBox"
|
||||
title={t("noSiteLinksSearchBox.title")}
|
||||
text={t("noSiteLinksSearchBox.text")}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
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>>({
|
||||
form,
|
||||
ms,
|
||||
title,
|
||||
text,
|
||||
formKey,
|
||||
disabled,
|
||||
}: {
|
||||
form: Modify<
|
||||
UseFormReturnType<TFormValue, () => TFormValue>,
|
||||
{
|
||||
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
>;
|
||||
formKey: keyof TFormValue;
|
||||
ms?: MantineSpacing;
|
||||
title: string;
|
||||
text: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFieldValue(formKey, (previous) => !previous);
|
||||
}, [form, formKey, disabled]);
|
||||
|
||||
return (
|
||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold">{title}</Text>
|
||||
<Text c="gray.5" fz={{ base: "xs", md: "sm" }}>
|
||||
{text}
|
||||
</Text>
|
||||
</Stack>
|
||||
</UnstyledButton>
|
||||
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { AnalyticsSettings } from "./_components/analytics.settings";
|
||||
|
||||
@@ -24,6 +25,7 @@ export default async function SettingsPage() {
|
||||
<Stack>
|
||||
<Title order={1}>{t("title")}</Title>
|
||||
<AnalyticsSettings initialData={serverSettings.analytics} />
|
||||
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import crowdinContributors from "../../../../../../../../static-data/translators.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(crowdinContributors);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import githubContributors from "../../../../../../../../static-data/contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(githubContributors);
|
||||
};
|
||||
|
||||
export const dynamic = "force-static";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import SuperJSON from "superjson";
|
||||
|
||||
import { db, eq } from "@homarr/db";
|
||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||
|
||||
export const SearchEngineOptimization = async () => {
|
||||
const crawlingAndIndexingSetting = await db.query.serverSettings.findFirst({
|
||||
where: eq(serverSettings.settingKey, "crawlingAndIndexing"),
|
||||
});
|
||||
|
||||
if (!crawlingAndIndexingSetting) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = SuperJSON.parse<(typeof defaultServerSettings)["crawlingAndIndexing"]>(
|
||||
crawlingAndIndexingSetting.value,
|
||||
);
|
||||
|
||||
const robotsAttributes = [...(value.noIndex ? ["noindex"] : []), ...(value.noIndex ? ["nofollow"] : [])];
|
||||
|
||||
const googleAttributes = [
|
||||
...(value.noSiteLinksSearchBox ? ["nositelinkssearchbox"] : []),
|
||||
...(value.noTranslate ? ["notranslate"] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<meta name="robots" content={robotsAttributes.join(",")} />
|
||||
<meta name="google" content={googleAttributes.join(",")} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
189
apps/nextjs/src/components/manage/boards/import-board-modal.tsx
Normal file
189
apps/nextjs/src/components/manage/boards/import-board-modal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Fieldset, FileInput, Grid, Group, Radio, Stack, Switch, TextInput } from "@mantine/core";
|
||||
import { IconFileUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useZodForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { SelectWithDescription } from "@homarr/ui";
|
||||
import type { OldmarrImportConfiguration } from "@homarr/validation";
|
||||
import { createOldmarrImportConfigurationSchema, superRefineJsonImportFile, z } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
|
||||
|
||||
interface InnerProps {
|
||||
boardNames: string[];
|
||||
}
|
||||
|
||||
export const ImportBoardModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const tOldImport = useScopedI18n("board.action.oldImport");
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [fileValid, setFileValid] = useState(true);
|
||||
const form = useZodForm(
|
||||
z.object({
|
||||
file: z.instanceof(File).nullable().superRefine(superRefineJsonImportFile),
|
||||
configuration: createOldmarrImportConfigurationSchema(innerProps.boardNames),
|
||||
}),
|
||||
{
|
||||
initialValues: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: null!,
|
||||
configuration: {
|
||||
distinctAppsByHref: true,
|
||||
onlyImportApps: false,
|
||||
screenSize: "lg",
|
||||
sidebarBehaviour: "last-section",
|
||||
name: "",
|
||||
},
|
||||
},
|
||||
onValuesChange(values, previous) {
|
||||
// This is a workarround until async validation is supported by mantine
|
||||
void (async () => {
|
||||
if (values.file === previous.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!values.file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await values.file.text();
|
||||
const result = oldmarrConfigSchema.safeParse(JSON.parse(content));
|
||||
|
||||
if (!result.success) {
|
||||
console.error(result.error.errors);
|
||||
setFileValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setFileValid(true);
|
||||
form.setFieldValue("configuration.name", result.data.configProperties.name);
|
||||
})();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = clientApi.board.importOldmarrConfig.useMutation();
|
||||
|
||||
const handleSubmitAsync = async (values: { file: File; configuration: OldmarrImportConfiguration }) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", values.file);
|
||||
formData.set("configuration", JSON.stringify(values.configuration));
|
||||
|
||||
await mutateAsync(formData, {
|
||||
async onSuccess() {
|
||||
actions.closeModal();
|
||||
await revalidatePathActionAsync("/manage/boards");
|
||||
showSuccessNotification({
|
||||
title: tOldImport("notification.success.title"),
|
||||
message: tOldImport("notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: tOldImport("notification.error.title"),
|
||||
message: tOldImport("notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (!fileValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleSubmitAsync({
|
||||
// It's checked for null in the superrefine
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
file: values.file!,
|
||||
configuration: values.configuration,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<Stack>
|
||||
<FileInput
|
||||
rightSection={<IconFileUpload />}
|
||||
withAsterisk
|
||||
accept="application/json"
|
||||
{...form.getInputProps("file")}
|
||||
error={
|
||||
(form.getInputProps("file").error as string | undefined) ??
|
||||
(!fileValid && form.isDirty("file") ? tOldImport("form.file.invalidError") : undefined)
|
||||
}
|
||||
type="button"
|
||||
label={tOldImport("form.file.label")}
|
||||
/>
|
||||
|
||||
<Fieldset legend={tOldImport("form.apps.label")}>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Switch
|
||||
label={tOldImport("form.apps.avoidDuplicates.label")}
|
||||
description={tOldImport("form.apps.avoidDuplicates.description")}
|
||||
{...form.getInputProps("configuration.distinctAppsByHref", { type: "checkbox" })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<Switch
|
||||
label={tOldImport("form.apps.onlyImportApps.label")}
|
||||
description={tOldImport("form.apps.onlyImportApps.description")}
|
||||
{...form.getInputProps("configuration.onlyImportApps", { type: "checkbox" })}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Fieldset>
|
||||
|
||||
<TextInput withAsterisk label={tOldImport("form.name.label")} {...form.getInputProps("configuration.name")} />
|
||||
|
||||
<Radio.Group
|
||||
withAsterisk
|
||||
label={tOldImport("form.screenSize.label")}
|
||||
{...form.getInputProps("configuration.screenSize")}
|
||||
>
|
||||
<Group mt="xs">
|
||||
<Radio value="sm" label={tOldImport("form.screenSize.option.sm")} />
|
||||
<Radio value="md" label={tOldImport("form.screenSize.option.md")} />
|
||||
<Radio value="lg" label={tOldImport("form.screenSize.option.lg")} />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
<SelectWithDescription
|
||||
withAsterisk
|
||||
label={tOldImport("form.sidebarBehavior.label")}
|
||||
description={tOldImport("form.sidebarBehavior.description")}
|
||||
data={[
|
||||
{
|
||||
value: "last-section",
|
||||
label: tOldImport("form.sidebarBehavior.option.lastSection.label"),
|
||||
description: tOldImport("form.sidebarBehavior.option.lastSection.description"),
|
||||
},
|
||||
{
|
||||
value: "remove-items",
|
||||
label: tOldImport("form.sidebarBehavior.option.removeItems.label"),
|
||||
description: tOldImport("form.sidebarBehavior.option.removeItems.description"),
|
||||
},
|
||||
]}
|
||||
{...form.getInputProps("configuration.sidebarBehaviour")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button variant="subtle" color="gray" onClick={actions.closeModal}>
|
||||
{tCommon("action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{tCommon("action.import")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("board.action.oldImport.label"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -46,9 +46,9 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node": "^20.16.5",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"prettier": "^3.3.3",
|
||||
"tsx": "4.13.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
16
package.json
16
package.json
@@ -27,20 +27,20 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"devDependencies": {
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@turbo/gen": "^2.1.1",
|
||||
"@turbo/gen": "^2.1.2",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
"@vitest/ui": "^2.0.5",
|
||||
"@vitest/coverage-v8": "^2.1.1",
|
||||
"@vitest/ui": "^2.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"jsdom": "^25.0.0",
|
||||
"prettier": "^3.3.3",
|
||||
"testcontainers": "^10.13.0",
|
||||
"turbo": "^2.1.1",
|
||||
"typescript": "^5.5.4",
|
||||
"testcontainers": "^10.13.1",
|
||||
"turbo": "^2.1.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.0.5"
|
||||
"vitest": "^2.1.1"
|
||||
},
|
||||
"packageManager": "pnpm@9.9.0",
|
||||
"packageManager": "pnpm@9.10.0",
|
||||
"engines": {
|
||||
"node": ">=20.17.0"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/integrations": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^",
|
||||
"@homarr/old-import": "workspace:^0.1.0",
|
||||
"@homarr/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/ping": "workspace:^0.1.0",
|
||||
"@homarr/redis": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
@@ -37,7 +39,7 @@
|
||||
"@trpc/react-query": "next",
|
||||
"@trpc/server": "next",
|
||||
"dockerode": "^4.0.2",
|
||||
"next": "^14.2.8",
|
||||
"next": "^14.2.11",
|
||||
"react": "^18.3.1",
|
||||
"superjson": "2.2.1",
|
||||
"trpc-swagger": "^1.2.6"
|
||||
@@ -47,8 +49,8 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/dockerode": "^3.3.31",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }))
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
} from "@homarr/db/schema/sqlite";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, widgetKinds } from "@homarr/definitions";
|
||||
import { importAsync } from "@homarr/old-import";
|
||||
import { oldmarrConfigSchema } from "@homarr/old-schema";
|
||||
import type { BoardItemAdvancedOptions } from "@homarr/validation";
|
||||
import { createSectionSchema, sharedItemSchema, validation, z } from "@homarr/validation";
|
||||
|
||||
@@ -451,6 +453,13 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
});
|
||||
}),
|
||||
importOldmarrConfig: protectedProcedure
|
||||
.input(validation.board.importOldmarrConfig)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const content = await input.file.text();
|
||||
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
|
||||
await importAsync(ctx.db, oldmarr, input.configuration);
|
||||
}),
|
||||
});
|
||||
|
||||
const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredIds: 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,8 @@ 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";
|
||||
import { notebookRouter } from "./notebook";
|
||||
@@ -17,6 +19,8 @@ export const widgetRouter = createTRPCRouter({
|
||||
smartHome: smartHomeRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
calendar: calendarRouter,
|
||||
downloads: downloadsRouter,
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
rssFeed: rssFeedRouter,
|
||||
indexerManager: indexerManagerRouter,
|
||||
});
|
||||
|
||||
80
packages/api/src/router/widgets/indexer-manager.ts
Normal file
80
packages/api/src/router/widgets/indexer-manager.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
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(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = integrationCreator(integration);
|
||||
const indexers = await client.getIndexersAsync().catch((err) => {
|
||||
logger.error("indexer-manager router - ", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to fetch indexers for ${integration.name} (${integration.id})`,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
indexers,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
|
||||
subscribeIndexersStatus: publicProcedure
|
||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
for (const integration of ctx.integrations) {
|
||||
const channel = createItemAndIntegrationChannel<Indexer[]>("indexerManager", integration.id);
|
||||
const unsubscribe = channel.subscribe((indexers) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
indexers,
|
||||
});
|
||||
});
|
||||
unsubscribes.push(unsubscribe);
|
||||
}
|
||||
return () => {
|
||||
unsubscribes.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
testAllIndexers: publicProcedure
|
||||
.unstable_concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = integrationCreator(integration);
|
||||
await client.testAllAsync().catch((err) => {
|
||||
logger.error("indexer-manager router - ", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Failed to test all indexers for ${integration.name} (${integration.id})`,
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"@t3-oss/env-nextjs": "^0.11.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cookies": "^0.9.1",
|
||||
"ldapts": "7.1.1",
|
||||
"next": "^14.2.8",
|
||||
"ldapts": "7.2.0",
|
||||
"next": "^14.2.11",
|
||||
"next-auth": "5.0.0-beta.20",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -45,8 +45,8 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cookies": "0.9.0",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,15 @@
|
||||
"prettier": "@homarr/prettier-config",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^14.2.8",
|
||||
"next": "^14.2.11",
|
||||
"react": "^18.3.1",
|
||||
"tldts": "^6.1.42"
|
||||
"tldts": "^6.1.44"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
import { mediaRequestsJob } from "./jobs/integrations/media-requests";
|
||||
import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
@@ -16,8 +18,10 @@ export const jobGroup = createCronJobGroup({
|
||||
smartHomeEntityState: smartHomeEntityStateJob,
|
||||
mediaServer: mediaServerJob,
|
||||
mediaOrganizer: mediaOrganizerJob,
|
||||
downloads: downloadsJob,
|
||||
mediaRequests: mediaRequestsJob,
|
||||
rssFeeds: rssFeedsJob,
|
||||
indexerManager: indexerManagerJob,
|
||||
});
|
||||
|
||||
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];
|
||||
|
||||
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) {
|
||||
|
||||
19
packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
Normal file
19
packages/cron-jobs/src/jobs/integrations/indexer-manager.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
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 getItemsWithIntegrationsAsync(db, {
|
||||
kinds: ["indexerManager"],
|
||||
});
|
||||
|
||||
for (const itemForIntegration of itemsForIntegration) {
|
||||
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 { SonarrIntegration } 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,43 +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 sonarr = new SonarrIntegration({
|
||||
...integration.integration,
|
||||
decryptedSecrets: integration.integration.secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
});
|
||||
const events = await sonarr.getCalendarEventsAsync(start, end);
|
||||
//Asserting the integration kind until all of them get implemented
|
||||
const integrationInstance = integrationCreatorFromSecrets(
|
||||
integration as Modify<typeof integration, { kind: "sonarr" | "radarr" }>,
|
||||
);
|
||||
|
||||
const cache = createItemAndIntegrationChannel<CalendarEvent[]>("calendar", integration.integrationId);
|
||||
const events = await integrationInstance.getCalendarEventsAsync(start, end);
|
||||
|
||||
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;
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
"@homarr/definitions": "workspace:^0.1.0",
|
||||
"@homarr/log": "workspace:^0.1.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"better-sqlite3": "^11.2.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.24.2",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"mysql2": "3.11.0"
|
||||
"mysql2": "3.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/better-sqlite3": "7.6.11",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint": "^9.10.0",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +11,10 @@ export const widgetKinds = [
|
||||
"smartHome-executeAutomation",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
"downloads",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"rssFeed",
|
||||
"indexerManager",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,18 +24,23 @@
|
||||
},
|
||||
"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",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,48 @@
|
||||
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";
|
||||
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
|
||||
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
|
||||
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
|
||||
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 = {
|
||||
@@ -27,6 +51,12 @@ export const integrationCreators = {
|
||||
homeAssistant: HomeAssistantIntegration,
|
||||
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,16 +2,31 @@
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-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 { StreamSession } from "./interfaces/media-server/session";
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { logger } from "@homarr/log";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { Integration } from "../../base/integration";
|
||||
import type { CalendarEvent } from "../../calendar-types";
|
||||
|
||||
export class RadarrIntegration extends Integration {
|
||||
/**
|
||||
* Priority list that determines the quality of images using their order.
|
||||
* Types at the start of the list are better than those at the end.
|
||||
* We do this to attempt to find the best quality image for the show.
|
||||
*/
|
||||
private readonly priorities: z.infer<typeof radarrCalendarEventSchema>["images"][number]["coverType"][] = [
|
||||
"poster", // Official, perfect aspect ratio
|
||||
"banner", // Official, bad aspect ratio
|
||||
"fanart", // Unofficial, possibly bad quality
|
||||
"screenshot", // Bad aspect ratio, possibly bad quality
|
||||
"clearlogo", // Without background, bad aspect ratio
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the events in the Radarr calendar between two dates.
|
||||
* @param start The start date
|
||||
* @param end The end date
|
||||
* @param includeUnmonitored When true results will include unmonitored items of the Tadarr library.
|
||||
*/
|
||||
async getCalendarEventsAsync(start: Date, end: Date, includeUnmonitored = true): Promise<CalendarEvent[]> {
|
||||
const url = new URL(this.integration.url);
|
||||
url.pathname = "/api/v3/calendar";
|
||||
url.searchParams.append("start", start.toISOString());
|
||||
url.searchParams.append("end", end.toISOString());
|
||||
url.searchParams.append("unmonitored", includeUnmonitored ? "true" : "false");
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-Api-Key": super.getSecretValue("apiKey"),
|
||||
},
|
||||
});
|
||||
const radarrCalendarEvents = await z.array(radarrCalendarEventSchema).parseAsync(await response.json());
|
||||
|
||||
return radarrCalendarEvents.map(
|
||||
(radarrCalendarEvent): CalendarEvent => ({
|
||||
name: radarrCalendarEvent.title,
|
||||
subName: radarrCalendarEvent.originalTitle,
|
||||
description: radarrCalendarEvent.overview,
|
||||
thumbnail: this.chooseBestImageAsURL(radarrCalendarEvent),
|
||||
date: radarrCalendarEvent.inCinemas,
|
||||
mediaInformation: {
|
||||
type: "movie",
|
||||
},
|
||||
links: this.getLinksForRadarrCalendarEvent(radarrCalendarEvent),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||
const links: CalendarEvent["links"] = [
|
||||
{
|
||||
href: `${this.integration.url}/movie/${event.titleSlug}`,
|
||||
name: "Radarr",
|
||||
logo: "/images/apps/radarr.svg",
|
||||
color: undefined,
|
||||
notificationColor: "yellow",
|
||||
isDark: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (event.imdbId) {
|
||||
links.push({
|
||||
href: `https://www.imdb.com/title/${event.imdbId}/`,
|
||||
name: "IMDb",
|
||||
color: "#f5c518",
|
||||
isDark: false,
|
||||
logo: "/images/apps/imdb.png",
|
||||
});
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
private chooseBestImage = (
|
||||
event: z.infer<typeof radarrCalendarEventSchema>,
|
||||
): z.infer<typeof radarrCalendarEventSchema>["images"][number] | undefined => {
|
||||
const flatImages = [...event.images];
|
||||
|
||||
const sortedImages = flatImages.sort(
|
||||
(imageA, imageB) => this.priorities.indexOf(imageA.coverType) - this.priorities.indexOf(imageB.coverType),
|
||||
);
|
||||
logger.debug(`Sorted images to [${sortedImages.map((image) => image.coverType).join(",")}]`);
|
||||
return sortedImages[0];
|
||||
};
|
||||
|
||||
private chooseBestImageAsURL = (event: z.infer<typeof radarrCalendarEventSchema>): string | undefined => {
|
||||
const bestImage = this.chooseBestImage(event);
|
||||
if (!bestImage) {
|
||||
return undefined;
|
||||
}
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
await super.handleTestConnectionResponseAsync({
|
||||
queryFunctionAsync: async () => {
|
||||
return await fetch(`${this.integration.url}/api`, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const radarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
coverType: z.enum(["screenshot", "poster", "banner", "fanart", "clearlogo"]),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
);
|
||||
|
||||
const radarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
originalTitle: z.string(),
|
||||
inCinemas: z.string().transform((value) => new Date(value)),
|
||||
overview: z.string().optional(),
|
||||
titleSlug: z.string(),
|
||||
images: radarrCalendarEventImageSchema,
|
||||
imdbId: z.string().optional(),
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export class ProwlarrIntegration extends Integration {
|
||||
name: indexer.name,
|
||||
url: indexer.indexerUrls[0] ?? "",
|
||||
enabled: indexer.enable,
|
||||
status: inactiveIndexerIds.has(indexer.id),
|
||||
status: !inactiveIndexerIds.has(indexer.id),
|
||||
}));
|
||||
|
||||
return indexers;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./calendar-types";
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./interfaces/indexer-manager/indexer";
|
||||
export * from "./interfaces/media-requests/media-request";
|
||||
|
||||
@@ -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>
|
||||
@@ -34,7 +34,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@
|
||||
"dependencies": {
|
||||
"@homarr/ui": "workspace:^0.1.0",
|
||||
"@mantine/notifications": "^7.12.2",
|
||||
"@tabler/icons-react": "^3.14.0"
|
||||
"@tabler/icons-react": "^3.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.9.1",
|
||||
"typescript": "^5.5.4"
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/old-import/eslint.config.js
Normal file
9
packages/old-import/eslint.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import baseConfig from "@homarr/eslint-config/base";
|
||||
|
||||
/** @type {import('typescript-eslint').Config} */
|
||||
export default [
|
||||
{
|
||||
ignores: [],
|
||||
},
|
||||
...baseConfig,
|
||||
];
|
||||
1
packages/old-import/index.ts
Normal file
1
packages/old-import/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./src";
|
||||
40
packages/old-import/package.json
Normal file
40
packages/old-import/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@homarr/old-import",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"lint": "eslint",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/old-schema": "workspace:^0.1.0",
|
||||
"@homarr/validation": "workspace:^0.1.0",
|
||||
"superjson": "2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"eslint": "^9.10.0",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"prettier": "@homarr/prettier-config"
|
||||
}
|
||||
49
packages/old-import/src/fix-section-issues.ts
Normal file
49
packages/old-import/src/fix-section-issues.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createId } from "@homarr/db";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { OldmarrConfig } from "@homarr/old-schema";
|
||||
|
||||
export const fixSectionIssues = (old: OldmarrConfig) => {
|
||||
const wrappers = old.wrappers.sort((wrapperA, wrapperB) => wrapperA.position - wrapperB.position);
|
||||
const categories = old.categories.sort((categoryA, categoryB) => categoryA.position - categoryB.position);
|
||||
|
||||
const neededSectionCount = categories.length * 2 + 1;
|
||||
const hasToMuchEmptyWrappers = wrappers.length > categories.length + 1;
|
||||
|
||||
logger.debug(
|
||||
`Fixing section issues neededSectionCount=${neededSectionCount} hasToMuchEmptyWrappers=${hasToMuchEmptyWrappers}`,
|
||||
);
|
||||
|
||||
for (let position = 0; position < neededSectionCount; position++) {
|
||||
const index = Math.floor(position / 2);
|
||||
const isEmpty = position % 2 === 0;
|
||||
const section = isEmpty ? wrappers[index] : categories[index];
|
||||
if (!section) {
|
||||
// If there are not enough empty sections for categories we need to insert them
|
||||
if (isEmpty) {
|
||||
// Insert empty wrapper for between categories
|
||||
wrappers.push({
|
||||
id: createId(),
|
||||
position,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
section.position = position;
|
||||
}
|
||||
|
||||
// Find all wrappers that should be merged into one
|
||||
const wrapperIdsToMerge = wrappers.slice(categories.length).map((section) => section.id);
|
||||
// Remove all wrappers after the first at the end
|
||||
wrappers.splice(categories.length + 1);
|
||||
|
||||
if (wrapperIdsToMerge.length >= 2) {
|
||||
logger.debug(`Found wrappers to merge count=${wrapperIdsToMerge.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
wrappers,
|
||||
categories,
|
||||
wrapperIdsToMerge,
|
||||
};
|
||||
};
|
||||
59
packages/old-import/src/import-apps.ts
Normal file
59
packages/old-import/src/import-apps.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createId, inArray } from "@homarr/db";
|
||||
import type { Database, InferInsertModel } from "@homarr/db";
|
||||
import { apps as appsTable } from "@homarr/db/schema/sqlite";
|
||||
import { logger } from "@homarr/log";
|
||||
import type { OldmarrApp } from "@homarr/old-schema";
|
||||
|
||||
export const insertAppsAsync = async (
|
||||
db: Database,
|
||||
apps: OldmarrApp[],
|
||||
distinctAppsByHref: boolean,
|
||||
configName: string,
|
||||
) => {
|
||||
logger.info(
|
||||
`Importing old homarr apps configuration=${configName} distinctAppsByHref=${distinctAppsByHref} apps=${apps.length}`,
|
||||
);
|
||||
const existingAppsWithHref = distinctAppsByHref
|
||||
? await db.query.apps.findMany({
|
||||
where: inArray(appsTable.href, [...new Set(apps.map((app) => app.url))]),
|
||||
})
|
||||
: [];
|
||||
|
||||
logger.debug(`Found existing apps with href count=${existingAppsWithHref.length}`);
|
||||
|
||||
const mappedApps = apps.map((app) => ({
|
||||
// Use id of existing app when it has the same href and distinctAppsByHref is true
|
||||
newId: distinctAppsByHref
|
||||
? (existingAppsWithHref.find(
|
||||
(existingApp) =>
|
||||
existingApp.href === (app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl) &&
|
||||
existingApp.name === app.name &&
|
||||
existingApp.iconUrl === app.appearance.iconUrl,
|
||||
)?.id ?? createId())
|
||||
: createId(),
|
||||
...app,
|
||||
}));
|
||||
|
||||
const appsToCreate = mappedApps
|
||||
.filter((app) => !existingAppsWithHref.some((existingApp) => existingApp.id === app.newId))
|
||||
.map(
|
||||
(app) =>
|
||||
({
|
||||
id: app.newId,
|
||||
name: app.name,
|
||||
iconUrl: app.appearance.iconUrl,
|
||||
href: app.behaviour.externalUrl === "" ? app.url : app.behaviour.externalUrl,
|
||||
description: app.behaviour.tooltipDescription,
|
||||
}) satisfies InferInsertModel<typeof appsTable>,
|
||||
);
|
||||
|
||||
logger.debug(`Creating apps count=${appsToCreate.length}`);
|
||||
|
||||
if (appsToCreate.length > 0) {
|
||||
await db.insert(appsTable).values(appsToCreate);
|
||||
}
|
||||
|
||||
logger.info(`Imported apps count=${appsToCreate.length}`);
|
||||
|
||||
return mappedApps;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user