From 08d4472d8b612e11a8b4e5f09952a788e7bcdf8a Mon Sep 17 00:00:00 2001 From: Yossi Hillali Date: Sun, 8 Sep 2024 00:18:16 +0300 Subject: [PATCH] feat: indexer manager widget (#1057) * fix(deps): update tanstack-query monorepo to ^5.53.2 (#1055) Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>

Homarr

**Thank you for your contribution. Please ensure that your pull request meets the following pull request:** - [ ] Builds without warnings or errors (``pnpm buid``, autofix with ``pnpm format:fix``) - [ ] Pull request targets ``dev`` branch - [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/) - [ ] No shorthand variable names are used (eg. ``x``, ``y``, ``i`` or any abbrevation) * fix: requested changes * fix: requested changes * feat: add cron job * fix: review changes * fix: add missing oldmarr import mappings --------- Co-authored-by: Meier Lukas --- packages/api/src/router/widgets/index.ts | 2 + .../api/src/router/widgets/indexer-manager.ts | 75 ++++++++++++++++ packages/cron-jobs/src/index.ts | 2 + .../src/jobs/integrations/indexer-manager.ts | 42 +++++++++ packages/definitions/src/widget.ts | 1 + packages/integrations/src/index.ts | 1 + .../src/prowlarr/prowlarr-integration.ts | 2 +- packages/integrations/src/types.ts | 3 +- .../src/widgets/definitions/index.ts | 1 + packages/old-import/src/widgets/options.ts | 3 + packages/translation/src/lang/en.ts | 17 ++++ packages/widgets/src/index.tsx | 2 + .../widgets/src/indexer-manager/component.tsx | 85 +++++++++++++++++++ packages/widgets/src/indexer-manager/index.ts | 22 +++++ .../widgets/src/indexer-manager/serverData.ts | 27 ++++++ 15 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/router/widgets/indexer-manager.ts create mode 100644 packages/cron-jobs/src/jobs/integrations/indexer-manager.ts create mode 100644 packages/widgets/src/indexer-manager/component.tsx create mode 100644 packages/widgets/src/indexer-manager/index.ts create mode 100644 packages/widgets/src/indexer-manager/serverData.ts diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 7ab978bb0..dce122e9b 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -2,6 +2,7 @@ import { createTRPCRouter } from "../../trpc"; import { appRouter } from "./app"; import { calendarRouter } from "./calendar"; import { dnsHoleRouter } from "./dns-hole"; +import { indexerManagerRouter } from "./indexer-manager"; import { mediaRequestsRouter } from "./media-requests"; import { mediaServerRouter } from "./media-server"; import { notebookRouter } from "./notebook"; @@ -19,4 +20,5 @@ export const widgetRouter = createTRPCRouter({ calendar: calendarRouter, mediaRequests: mediaRequestsRouter, rssFeed: rssFeedRouter, + indexerManager: indexerManagerRouter, }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts new file mode 100644 index 000000000..9321f1074 --- /dev/null +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -0,0 +1,75 @@ +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; + +import { integrationCreatorByKind } from "@homarr/integrations"; +import type { Indexer } from "@homarr/integrations/types"; +import { logger } from "@homarr/log"; +import { createItemAndIntegrationChannel } from "@homarr/redis"; + +import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const indexerManagerRouter = createTRPCRouter({ + getIndexersStatus: publicProcedure + .unstable_concat(createManyIntegrationMiddleware("query", "prowlarr")) + .query(async ({ ctx }) => { + const results = await Promise.all( + ctx.integrations.map(async (integration) => { + const client = integrationCreatorByKind(integration.kind, 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(createManyIntegrationMiddleware("query", "prowlarr")) + .subscription(({ ctx }) => { + return observable<{ integrationId: string; indexers: Indexer[] }>((emit) => { + const unsubscribes: (() => void)[] = []; + for (const integration of ctx.integrations) { + const channel = createItemAndIntegrationChannel("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(createManyIntegrationMiddleware("interact", "prowlarr")) + .mutation(async ({ ctx }) => { + await Promise.all( + ctx.integrations.map(async (integration) => { + const client = integrationCreatorByKind(integration.kind, 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})`, + }); + }); + }), + ); + }), +}); diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index b28bd3eaa..94a57a0de 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -1,6 +1,7 @@ import { analyticsJob } from "./jobs/analytics"; import { iconsUpdaterJob } from "./jobs/icons-updater"; 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"; @@ -18,6 +19,7 @@ export const jobGroup = createCronJobGroup({ mediaOrganizer: mediaOrganizerJob, mediaRequests: mediaRequestsJob, rssFeeds: rssFeedsJob, + indexerManager: indexerManagerJob, }); export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number]; diff --git a/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts new file mode 100644 index 000000000..9d933afbc --- /dev/null +++ b/packages/cron-jobs/src/jobs/integrations/indexer-manager.ts @@ -0,0 +1,42 @@ +import { decryptSecret } from "@homarr/common"; +import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions"; +import { db, eq } from "@homarr/db"; +import { items } from "@homarr/db/schema/sqlite"; +import { ProwlarrIntegration } from "@homarr/integrations"; + +import { createCronJob } from "../../lib"; + +export const indexerManagerJob = createCronJob("indexerManager", EVERY_MINUTE).withCallback(async () => { + const itemsForIntegration = await db.query.items.findMany({ + where: eq(items.kind, "indexerManager"), + with: { + integrations: { + with: { + integration: { + with: { + secrets: { + columns: { + kind: true, + value: true, + }, + }, + }, + }, + }, + }, + }, + }); + + for (const itemForIntegration of itemsForIntegration) { + for (const integration of itemForIntegration.integrations) { + const prowlarr = new ProwlarrIntegration({ + ...integration.integration, + decryptedSecrets: integration.integration.secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }); + await prowlarr.getIndexersAsync(); + } + } +}); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index c6a498489..9fcbaacb7 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -14,5 +14,6 @@ export const widgetKinds = [ "mediaRequests-requestList", "mediaRequests-requestStats", "rssFeed", + "indexerManager", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 15d25d3d9..a79bd5c1f 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -7,6 +7,7 @@ export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; export { OverseerrIntegration } from "./overseerr/overseerr-integration"; export { PiHoleIntegration } from "./pi-hole/pi-hole-integration"; +export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; // Types export { MediaRequestStatus } from "./interfaces/media-requests/media-request"; diff --git a/packages/integrations/src/prowlarr/prowlarr-integration.ts b/packages/integrations/src/prowlarr/prowlarr-integration.ts index e023e21a5..8768073c4 100644 --- a/packages/integrations/src/prowlarr/prowlarr-integration.ts +++ b/packages/integrations/src/prowlarr/prowlarr-integration.ts @@ -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; diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index a39c1d577..445894eb4 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/old-import/src/widgets/definitions/index.ts b/packages/old-import/src/widgets/definitions/index.ts index 20abf310e..6ed52db7c 100644 --- a/packages/old-import/src/widgets/definitions/index.ts +++ b/packages/old-import/src/widgets/definitions/index.ts @@ -64,6 +64,7 @@ export const widgetKindMapping = { "smartHome-executeAutomation": "smart-home/trigger-automation", "mediaRequests-requestList": "media-requests-list", "mediaRequests-requestStats": "media-requests-stats", + indexerManager: "indexer-manager", } satisfies Record; // Use null for widgets that did not exist in oldmarr // TODO: revert assignment so that only old widgets are needed in the object, diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts index be0cf7217..6fef8c2a2 100644 --- a/packages/old-import/src/widgets/options.ts +++ b/packages/old-import/src/widgets/options.ts @@ -88,6 +88,9 @@ const optionMapping: OptionMapping = { displayName: (oldOptions) => oldOptions.displayName, }, mediaServer: {}, + indexerManager: { + openIndexerSiteInNewTab: (oldOptions) => oldOptions.openIndexerSiteInNewTab, + }, app: null, }; diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index cae59613d..c86bcdd5b 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -1065,6 +1065,20 @@ export default { unknown: "Unknown", }, }, + indexerManager: { + name: "Indexer manager status", + description: "Status of your indexers", + option: { + openIndexerSiteInNewTab: { + label: "Open indexer site in new tab", + }, + }, + title: "Indexer manager", + testAll: "Test all", + error: { + internalServerError: "Failed to fetch indexers status", + }, + }, common: { location: { query: "City / Postal code", @@ -1738,6 +1752,9 @@ export default { rssFeeds: { label: "RSS feeds", }, + indexerManager: { + label: "Indexer Manager", + }, }, }, }, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 0585f760d..4e3d42033 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -13,6 +13,7 @@ import * as dnsHoleControls from "./dns-hole/controls"; import * as dnsHoleSummary from "./dns-hole/summary"; import * as iframe from "./iframe"; import type { WidgetImportRecord } from "./import"; +import * as indexerManager from "./indexer-manager"; import * as mediaRequestsList from "./media-requests/list"; import * as mediaRequestsStats from "./media-requests/stats"; import * as mediaServer from "./media-server"; @@ -47,6 +48,7 @@ export const widgetImports = { "mediaRequests-requestList": mediaRequestsList, "mediaRequests-requestStats": mediaRequestsStats, rssFeed, + indexerManager, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx new file mode 100644 index 000000000..d9d79020f --- /dev/null +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { Anchor, Button, Card, Container, Flex, Group, ScrollArea, Text } from "@mantine/core"; +import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import type { Indexer } from "@homarr/integrations/types"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../definition"; +import { NoIntegrationSelectedError } from "../errors"; + +export default function IndexerManagerWidget({ + options, + integrationIds, + serverData, +}: WidgetComponentProps<"indexerManager">) { + if (integrationIds.length === 0) { + throw new NoIntegrationSelectedError(); + } + const t = useI18n(); + const [indexersData, setIndexersData] = useState<{ integrationId: string; indexers: Indexer[] }[]>( + serverData?.initialData ?? [], + ); + + const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation(); + + clientApi.widget.indexerManager.subscribeIndexersStatus.useSubscription( + { integrationIds }, + { + onData(newData) { + setIndexersData((prevData) => { + return prevData.map((item) => + item.integrationId === newData.integrationId ? { ...item, indexers: newData.indexers } : item, + ); + }); + }, + }, + ); + + return ( + + + {t("widget.indexerManager.title")} + + + + {indexersData.map(({ integrationId, indexers }) => ( + + {indexers.map((indexer) => ( + + + + {indexer.name} + + + {indexer.status === false || indexer.enabled === false ? ( + + ) : ( + + )} + + ))} + + ))} + + + + + ); +} diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts new file mode 100644 index 000000000..72c76876e --- /dev/null +++ b/packages/widgets/src/indexer-manager/index.ts @@ -0,0 +1,22 @@ +import { IconReportSearch, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader, serverDataLoader } = createWidgetDefinition("indexerManager", { + icon: IconReportSearch, + options: optionsBuilder.from((factory) => ({ + openIndexerSiteInNewTab: factory.switch({ + defaultValue: true, + }), + })), + supportedIntegrations: ["prowlarr"], + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.indexerManager.error.internalServerError"), + }, + }, +}) + .withServerData(() => import("./serverData")) + .withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/indexer-manager/serverData.ts b/packages/widgets/src/indexer-manager/serverData.ts new file mode 100644 index 000000000..4ec1c92d5 --- /dev/null +++ b/packages/widgets/src/indexer-manager/serverData.ts @@ -0,0 +1,27 @@ +"use server"; + +import { api } from "@homarr/api/server"; + +import type { WidgetProps } from "../definition"; + +export default async function getServerDataAsync({ integrationIds }: WidgetProps<"indexerManager">) { + if (integrationIds.length === 0) { + return { + initialData: [], + }; + } + + try { + const currentIndexers = await api.widget.indexerManager.getIndexersStatus({ + integrationIds, + }); + + return { + initialData: currentIndexers, + }; + } catch { + return { + initialData: [], + }; + } +}