From 5d113ea2806c682c7617dc55f2ddf563f8f3780a Mon Sep 17 00:00:00 2001 From: Yossi Hillali Date: Fri, 9 Feb 2024 23:35:56 +0200 Subject: [PATCH] Indexer manager (#1807) * indexer manager widget Co-authored-by: Tagaishi --- .../locales/en/modules/indexer-manager.json | 19 ++++ .../InputElements/IntegrationSelector.tsx | 9 +- src/server/api/root.ts | 5 +- src/server/api/routers/indexer-manager.ts | 102 ++++++++++++++++++ src/tools/server/translation-namespaces.ts | 3 +- src/types/app.ts | 5 +- src/widgets/index.ts | 2 + .../indexer-manager/IndexerManagerTile.tsx | 98 +++++++++++++++++ 8 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 public/locales/en/modules/indexer-manager.json create mode 100644 src/server/api/routers/indexer-manager.ts create mode 100644 src/widgets/indexer-manager/IndexerManagerTile.tsx diff --git a/public/locales/en/modules/indexer-manager.json b/public/locales/en/modules/indexer-manager.json new file mode 100644 index 000000000..051593223 --- /dev/null +++ b/public/locales/en/modules/indexer-manager.json @@ -0,0 +1,19 @@ +{ + "descriptor": { + "name": "Indexer manager status", + "description": "Status about your indexers", + "settings": { + "title": "Indexer manager status" + } + }, + "indexersStatus": { + "title": "Indexer manager", + "testAllButton": "Test all" + }, + "errors": { + "general": { + "title": "Unable to find a indexer manager", + "text": "There was a problem connecting to your indexer manager. Please verify your configuration/integration(s)." + } + } + } \ No newline at end of file diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index eb021a94c..21ad22500 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -163,6 +163,11 @@ export const availableIntegrations = [ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png', label: 'Readarr', }, + { + value: 'prowlarr', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prowlarr.png', + label: 'Prowlarr', + }, { value: 'jellyfin', image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png', @@ -186,6 +191,6 @@ export const availableIntegrations = [ { value: 'homeAssistant', image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png', - label: 'Home Assistant' - } + label: 'Home Assistant', + }, ] as const satisfies Readonly; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2b34e1b3e..0d1268079 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,4 +1,5 @@ import { createTRPCRouter } from '~/server/api/trpc'; + import { appRouter } from './routers/app'; import { boardRouter } from './routers/board'; import { calendarRouter } from './routers/calendar'; @@ -8,6 +9,7 @@ import { dnsHoleRouter } from './routers/dns-hole/router'; import { dockerRouter } from './routers/docker/router'; import { downloadRouter } from './routers/download'; import { iconRouter } from './routers/icon'; +import { indexerManagerRouter } from './routers/indexer-manager'; import { inviteRouter } from './routers/invite/invite-router'; import { mediaRequestsRouter } from './routers/media-request'; import { mediaServerRouter } from './routers/media-server'; @@ -30,6 +32,7 @@ export const rootRouter = createTRPCRouter({ rss: rssRouter, user: userRouter, calendar: calendarRouter, + indexerManager: indexerManagerRouter, config: configRouter, dashDot: dashDotRouter, dnsHole: dnsHoleRouter, @@ -45,7 +48,7 @@ export const rootRouter = createTRPCRouter({ boards: boardRouter, password: passwordRouter, notebook: notebookRouter, - smartHomeEntityState: smartHomeEntityStateRouter + smartHomeEntityState: smartHomeEntityStateRouter, }); // export type definition of API diff --git a/src/server/api/routers/indexer-manager.ts b/src/server/api/routers/indexer-manager.ts new file mode 100644 index 000000000..2fc43b2f5 --- /dev/null +++ b/src/server/api/routers/indexer-manager.ts @@ -0,0 +1,102 @@ +import axios from 'axios'; +import Consola from 'consola'; +import { z } from 'zod'; +import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; +import { IntegrationType } from '~/types/app'; + +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; + +export const indexerManagerRouter = createTRPCRouter({ + indexers: publicProcedure + .input( + z.object({ + configName: z.string(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[]; + const app = config.apps.find((app) => + checkIntegrationsType(app.integration, indexerAppIntegrationTypes) + )!; + const apiKey = findAppProperty(app, 'apiKey'); + if (!app || !apiKey) { + Consola.error( + `Failed to process request to indexer app (${app.id}): API key not found. Please check the configuration.` + ); + } + + const appUrl = new URL(app.url); + const data = await axios + .get(`${appUrl.origin}/api/v1/indexer`, { + headers: { + 'X-Api-Key': apiKey, + }, + }) + .then((res) => res.data); + return data; + }), + + statuses: publicProcedure + .input( + z.object({ + configName: z.string(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[]; + const app = config.apps.find((app) => + checkIntegrationsType(app.integration, indexerAppIntegrationTypes) + )!; + const apiKey = findAppProperty(app, 'apiKey'); + if (!app || !apiKey) { + Consola.error( + `Failed to process request to indexer app (${app.id}): API key not found. Please check the configuration.` + ); + } + + const appUrl = new URL(app.url); + const data = await axios + .get(`${appUrl.origin}/api/v1/indexerstatus`, { + headers: { + 'X-Api-Key': apiKey, + }, + }) + .then((res) => res.data); + return data; + }), + + testAllIndexers: protectedProcedure + .input( + z.object({ + configName: z.string(), + }) + ) + .mutation(async ({ input }) => { + const config = getConfig(input.configName); + const indexerAppIntegrationTypes = ['prowlarr'] as const satisfies readonly IntegrationType[]; + const app = config.apps.find((app) => + checkIntegrationsType(app.integration, indexerAppIntegrationTypes) + )!; + const apiKey = findAppProperty(app, 'apiKey'); + if (!app || !apiKey) { + Consola.error( + `failed to process request to app '${app?.integration}' (${app?.id}). Please check api key` + ); + } + + const appUrl = new URL(app.url); + const result = await axios + .post(`${appUrl.origin}/api/v1/indexer/testall`, null, { + headers: { + 'X-Api-Key': apiKey, + }, + }) + .then((res) => res.data) + .catch((err: any) => err.response.data); + + return result; + }), +}); diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index e505ba8a3..33487f83d 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -22,6 +22,7 @@ export const boardNamespaces = [ 'modules/dashdot', 'modules/overseerr', 'modules/media-server', + 'modules/indexer-manager', 'modules/common-media-cards', 'modules/video-stream', 'modules/media-requests-list', @@ -44,7 +45,7 @@ export const manageNamespaces = [ 'manage/users', 'manage/users/invites', 'manage/users/create', - 'manage/users/edit' + 'manage/users/edit', ]; export const loginNamespaces = ['authentication/login']; diff --git a/src/types/app.ts b/src/types/app.ts index 6c624ca5d..19258278c 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,5 +1,4 @@ import { Icon, IconKey, IconPassword, IconUser } from '@tabler/icons-react'; - import { Property } from 'csstype'; import { TileBaseType } from './tile'; @@ -46,6 +45,7 @@ export type IntegrationType = | 'radarr' | 'sonarr' | 'lidarr' + | 'prowlarr' | 'sabnzbd' | 'jellyseerr' | 'overseerr' @@ -87,6 +87,7 @@ export const integrationFieldProperties: { lidarr: ['apiKey'], radarr: ['apiKey'], sonarr: ['apiKey'], + prowlarr: ['apiKey'], sabnzbd: ['apiKey'], readarr: ['apiKey'], overseerr: ['apiKey'], @@ -99,7 +100,7 @@ export const integrationFieldProperties: { plex: ['apiKey'], pihole: ['apiKey'], adGuardHome: ['username', 'password'], - homeAssistant: ['apiKey'] + homeAssistant: ['apiKey'], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index b83187e2e..1bc9bd18f 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -6,6 +6,7 @@ import dnsHoleControls from './dnshole/DnsHoleControls'; import dnsHoleSummary from './dnshole/DnsHoleSummary'; import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile'; import iframe from './iframe/IFrameTile'; +import indexerManager from './indexer-manager/IndexerManagerTile'; import mediaRequestsList from './media-requests/MediaRequestListTile'; import mediaRequestsStats from './media-requests/MediaRequestStatsTile'; import mediaServer from './media-server/MediaServerTile'; @@ -20,6 +21,7 @@ import weather from './weather/WeatherTile'; export default { calendar, + 'indexer-manager': indexerManager, dashdot, usenet, weather, diff --git a/src/widgets/indexer-manager/IndexerManagerTile.tsx b/src/widgets/indexer-manager/IndexerManagerTile.tsx new file mode 100644 index 000000000..98807fa8f --- /dev/null +++ b/src/widgets/indexer-manager/IndexerManagerTile.tsx @@ -0,0 +1,98 @@ +import { Button, Card, Flex, Group, ScrollArea, Text } from '@mantine/core'; +import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from '@tabler/icons-react'; +import { useSession } from 'next-auth/react'; +import { useTranslation } from 'next-i18next'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; + +import { defineWidget } from '../helper'; +import { WidgetLoading } from '../loading'; +import { IWidget } from '../widgets'; + +const definition = defineWidget({ + id: 'indexer-manager', + icon: IconReportSearch, + options: {}, + gridstack: { + minWidth: 1, + minHeight: 1, + maxWidth: 3, + maxHeight: 3, + }, + component: IndexerManagerWidgetTile, +}); + +export type IIndexerManagerWidget = IWidget<(typeof definition)['id'], typeof definition>; + +interface IndexerManagerWidgetProps { + widget: IIndexerManagerWidget; +} + +function IndexerManagerWidgetTile({ widget }: IndexerManagerWidgetProps) { + const { t } = useTranslation('modules/indexer-manager'); + const { data: sessionData } = useSession(); + const { name: configName } = useConfigContext(); + const utils = api.useUtils(); + const { isLoading: testAllLoading, mutateAsync: testAllAsync } = + api.indexerManager.testAllIndexers.useMutation({ + onSuccess: async () => { + await utils.indexerManager.invalidate(); + }, + }); + const { isInitialLoading: indexersLoading, data: indexersData } = + api.indexerManager.indexers.useQuery({ + configName: configName!, + }); + const { isInitialLoading: statusesLoading, data: statusesData } = + api.indexerManager.statuses.useQuery( + { + configName: configName!, + }, + { + staleTime: 1000 * 60 * 2, + } + ); + if (indexersLoading || !indexersData || statusesLoading) { + return ; + } + + return ( + + {t('indexersStatus.title')} + + + {indexersData.map((indexer: any) => ( + + + {indexer.name} + + {!statusesData.find((status: any) => indexer.id === status.indexerId) && + indexer.enable ? ( + + ) : ( + + )} + + ))} + + + {sessionData && ( + + )} + + ); +} + +export default definition;