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 14 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
7 changes: 5 additions & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@
"cqmin",
"homarr",
"jellyfin",
"mantine",
"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);
}
135 changes: 135 additions & 0 deletions packages/api/src/router/widgets/downloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";

import type { IntegrationKind } from "@homarr/definitions";
import type { DownloadClientIntegration, IntegrationInput, SanitizedIntegration } from "@homarr/integrations";
import { integrationCreatorByKind } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";
import { z } from "@homarr/validation";

import type { DownloadClientJobsAndStatus } from "../../../../integrations/src/interfaces/downloads/download-client-data";
import type { DownloadClientItem } from "../../../../integrations/src/interfaces/downloads/download-client-items";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";

export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async ({ decryptedSecrets: _, ...integration }) => {
const channel = createItemAndIntegrationChannel<DownloadClientJobsAndStatus>("downloads", integration.id);
const data = await channel.getAsync();
return {
integration: integration as SanitizedIntegration,
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
data: data?.data ?? ({} as DownloadClientJobsAndStatus),
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
};
}),
);
}),
subscribeToJobsAndStatuses: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("query", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.subscription(({ ctx }) => {
return observable<{ integration: SanitizedIntegration; 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((sessions) => {
emit.next({
integration: integration as SanitizedIntegration,
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
data: sessions,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
pause: publicProcedure
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
.unstable_concat(
createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = getIntegrationInstance(integration.kind, integration);
await integrationInstance.pauseQueueAsync();
}),
);
}),
pauseItem: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.input(z.object({ item: z.any() satisfies z.ZodType<DownloadClientItem> }))
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = getIntegrationInstance(integration.kind, integration);
await integrationInstance.pauseItemAsync(input.item as DownloadClientItem);
}),
);
}),
resume: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = getIntegrationInstance(integration.kind, integration);
await integrationInstance.resumeQueueAsync();
}),
);
}),
resumeItem: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.input(z.object({ item: z.any() satisfies z.ZodType<DownloadClientItem> }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = getIntegrationInstance(integration.kind, integration);
await integrationInstance.resumeItemAsync(input.item as DownloadClientItem);
}),
);
}),
deleteItem: publicProcedure
.unstable_concat(
createManyIntegrationMiddleware("interact", "sabNzbd", "nzbGet", "qBittorrent", "deluge", "transmission"),
)
.input(z.object({ item: z.any() satisfies z.ZodType<DownloadClientItem>, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = getIntegrationInstance(integration.kind, integration);
await integrationInstance.deleteItemAsync(input.item as DownloadClientItem, input.fromDisk);
}),
);
}),
});

function getIntegrationInstance(kind: IntegrationKind, integration: IntegrationInput): DownloadClientIntegration {
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
switch (kind) {
case "sabNzbd":
case "nzbGet":
case "qBittorrent":
case "deluge":
case "transmission":
return integrationCreatorByKind(kind, integration) as DownloadClientIntegration;
default:
throw new TRPCError({
code: "BAD_REQUEST",
});
}
}
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.

20 changes: 20 additions & 0 deletions packages/common/src/number.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ const ranges = [
{ divider: 1e3, suffix: "k" },
];

//64bit limit for Number stops at EiB
const siRanges = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
SeDemal marked this conversation as resolved.
Show resolved Hide resolved

export const formatNumber = (value: number, decimalPlaces: number) => {
for (const range of ranges) {
if (value < range.divider) continue;
Expand All @@ -19,3 +22,20 @@ 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 are should be integer.
*/
export const humanFileSize = (size: number) => {
if (!Number.isInteger(size)) return NaN;
SeDemal marked this conversation as resolved.
Show resolved Hide resolved
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];
}
count++;
}
return "∞";
};
4 changes: 2 additions & 2 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
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 { mediaOrganizerJob } from "./jobs/integrations/media-organizer";
import { mediaServerJob } from "./jobs/integrations/media-server";
import { pingJob } from "./jobs/ping";
import type { RssFeed } from "./jobs/rss-feeds";
import { rssFeedsJob } from "./jobs/rss-feeds";
import { createCronJobGroup } from "./lib";
import { usenetDownloadsJob } from "./jobs/integrations/usenet-downloads";

export const jobGroup = createCronJobGroup({
analytics: analyticsJob,
Expand All @@ -16,7 +16,7 @@ export const jobGroup = createCronJobGroup({
smartHomeEntityState: smartHomeEntityStateJob,
mediaServer: mediaServerJob,
mediaOrganizer: mediaOrganizerJob,
usenetDownloads: usenetDownloadsJob,
downloads: downloadsJob,
rssFeeds: rssFeedsJob,
});

Expand Down
Loading