Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: download clients integration and widget #895

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e133977
feat: initial commit
Jul 31, 2024
6700fce
Merge branch 'feature/usenet-downloads' of https://github.com/homarr-…
Jul 31, 2024
fb8b15f
fix: javacript errors and mistakes
Jul 31, 2024
56f2b44
chore: misc
Jul 31, 2024
134728a
feat: NzbGet API
Aug 1, 2024
3ed7adc
feat: add default sorting and fix Icon sizing warnings
Aug 1, 2024
b284f1f
fix: with low opacity header not readable
Meierschlumpf Aug 2, 2024
f845d08
Merge branch 'dev' into feat/download-clients-integration-and-widget
Meierschlumpf Aug 2, 2024
0a99a77
fix: fixed table header, console warnings and improve subscription
Aug 2, 2024
a816322
Merge branch 'feat/download-clients-integration-and-widget' of https:…
Aug 3, 2024
e1f861b
fix: merged incorrectly
Aug 3, 2024
b3c1471
Merge branch 'feature/usenet-downloads' into feat/download-clients-in…
SeDemal Aug 8, 2024
3c7f5ce
fix: remove erroneously re-added "usenet-downloads" mentions and files
Aug 8, 2024
61b42ae
chore: cleaning and format things
Aug 9, 2024
e4c7926
feat: standardize sizes and group them in baseStyle
Aug 9, 2024
84a72f4
Merge branch 'feature/usenet-downloads' into feat/download-clients-in…
SeDemal Aug 9, 2024
c86c176
feat: Add option hide depending on integration selection
SeDemal Aug 9, 2024
67d7186
refactor: address comments
SeDemal Aug 13, 2024
d524484
feat: Get integrations by category + various fixes
SeDemal Aug 14, 2024
1fb3d28
fix: broken lock file
SeDemal Aug 14, 2024
12605b4
fix: last comments and small refactors
SeDemal Aug 14, 2024
1ee663f
fix: adapt humanFileSize uses
SeDemal Aug 14, 2024
6f3c6b0
refactor: better typing of IntegrationKindByCategory
SeDemal Aug 15, 2024
653d2e4
fix: createXIntegrationMiddleware to accept Arrays
SeDemal Aug 15, 2024
9ea0dc8
refactor: revert change of createXIntegrationMiddleware and normalize…
SeDemal Aug 15, 2024
94ee417
fix: lint errors
SeDemal Aug 20, 2024
99320ad
refactor: extract typing
SeDemal Aug 25, 2024
1b3be40
feat: add hooks for getting all integrations with certain access
Meierschlumpf Aug 25, 2024
da3cac1
Merge branch 'feat/download-clients-integration-and-widget' of https:…
Meierschlumpf Aug 25, 2024
86250a4
feat: Add tests
SeDemal Sep 4, 2024
287b61e
feat: hide controls depending on permissions
SeDemal Sep 4, 2024
47ef25a
chore: further use of AtLeastOneOf
SeDemal Sep 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,7 @@ db.sqlite
*.log

apps/tasks/tasks.cjs
apps/websocket/wssServer.cjs
apps/websocket/wssServer.cjs

#personal backgrounds
apps/nextjs/public/images/background.png
12 changes: 10 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@
"js/ts.implicitProjectConfig.experimentalDecorators": true,
"prettier.configPath": "./tooling/prettier/index.mjs",
"cSpell.words": [
"ajnart",
"cqmin",
"gridstack",
"homarr",
"jellyfin",
"mantine",
"manuel-rw",
"Meierschlumpf",
"Sabnzbd",
"SeDemal",
"Sonarr",
"superjson",
"tabler",
"trpc",
"Umami",
"Sonarr"
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
Expand Down
15 changes: 13 additions & 2 deletions apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
4 changes: 4 additions & 0 deletions apps/nextjs/src/app/[locale]/widgets/[kind]/_content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
Expand All @@ -105,6 +108,7 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
isEditMode={editMode}
boardId={undefined}
itemId={undefined}
setOptions={updateOptions}
/>
</ErrorBoundary>
)}
Expand Down
4 changes: 4 additions & 0 deletions apps/nextjs/src/components/board/sections/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
const serverData = useServerDataFor(item.id);
const Comp = loadWidgetDynamic(item.kind);
const options = reduceWidgetOptionsWithDefaultValues(item.kind, item.options);
const { updateItemOptions } = useItemActions();
const newItem = { ...item, options };
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
updateItemOptions({ itemId: item.id, newOptions });

if (!serverData?.isReady) return null;

Expand All @@ -124,6 +127,7 @@ const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
isEditMode={isEditMode}
boardId={board.id}
itemId={item.id}
setOptions={updateOptions}
{...dimensions}
/>
</ErrorBoundary>
Expand Down
10 changes: 6 additions & 4 deletions apps/nextjs/src/components/board/sections/item.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
9 changes: 5 additions & 4 deletions packages/api/src/middlewares/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Session } from "@homarr/auth";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { decryptSecret } from "@homarr/common";
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";
Expand All @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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) }))
Expand Down Expand Up @@ -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() }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions
import { getAllSecretKindOptions } from "@homarr/definitions";
import { integrationCreatorByKind, IntegrationTestConnectionError } from "@homarr/integrations";

import type { integrationCreators } from "../../../../integrations/src/base/creator";

type FormIntegration = Integration & {
secrets: {
kind: IntegrationSecretKind;
Expand Down Expand Up @@ -48,7 +50,8 @@ export const testConnectionAsync = async (
return secrets.find((secret) => secret.source === "form") ?? secrets[0]!;
});

const integrationInstance = integrationCreatorByKind(integration.kind, {
//Remove "as" conversion as soon as all integrations kinds have been made.
const integrationInstance = integrationCreatorByKind(integration.kind as keyof typeof integrationCreators, {
id: integration.id,
name: integration.name,
url: integration.url,
Expand Down
111 changes: 111 additions & 0 deletions packages/api/src/router/widgets/downloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { observable } from "@trpc/server/observable";

import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus, SanitizedIntegration } from "@homarr/integrations";
import { downloadClientItemSchema, integrationCreatorByKind } 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: SanitizedIntegration; 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 = integrationCreatorByKind(integration.kind, 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 = integrationCreatorByKind(integration.kind, 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 = integrationCreatorByKind(integration.kind, 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 = integrationCreatorByKind(integration.kind, 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 = integrationCreatorByKind(integration.kind, integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
}),
});
4 changes: 2 additions & 2 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { createTRPCRouter } from "../../trpc";
import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { mediaServerRouter } from "./media-server";
import { notebookRouter } from "./notebook";
import { rssFeedRouter } from "./rssFeed";
import { smartHomeRouter } from "./smart-home";
import { weatherRouter } from "./weather";
import {usenetDownloadsRouter} from "./usenet-downloads";

export const widgetRouter = createTRPCRouter({
notebook: notebookRouter,
Expand All @@ -17,6 +17,6 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
usenetDownloads: usenetDownloadsRouter,
downloads: downloadsRouter,
rssFeed: rssFeedRouter,
});
Loading