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 25 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
11 changes: 9 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
"cqmin",
"homarr",
"jellyfin",
"mantine",
"ajnart",
"Meierschlumpf",
"manuel-rw",
"SeDemal",
"Sabnzbd",
"Sonarr",
"superjson",
"tabler",
"trpc",
"Umami",
"Sonarr"
"Umami"
],
"i18n-ally.dirStructure": "auto",
"i18n-ally.enabledFrameworks": ["next-international"],
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 { 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,
});
80 changes: 0 additions & 80 deletions packages/api/src/router/widgets/usenet-downloads.ts

This file was deleted.

28 changes: 28 additions & 0 deletions packages/common/src/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "∞";
};
2 changes: 2 additions & 0 deletions packages/common/src/types.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export type MaybePromise<T> = T | Promise<T>;

export type AtLeastOneOf<T> = [T, ...T[]];
Loading