From 2be508ef2fad97b3068e60ba97c63218b6c045bf Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 09:57:20 +0200 Subject: [PATCH 01/36] =?UTF-8?q?=E2=9E=95=20Add=20tRPC=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 ++++ yarn.lock | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/package.json b/package.json index ff10f4775..ad0ed5288 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,10 @@ "@tanstack/react-query": "^4.2.1", "@tanstack/react-query-devtools": "^4.24.4", "@tanstack/react-query-persist-client": "^4.28.0", + "@trpc/client": "^10.29.1", + "@trpc/next": "^10.29.1", + "@trpc/react-query": "^10.29.1", + "@trpc/server": "^10.29.1", "@vitejs/plugin-react": "^4.0.0", "axios": "^1.0.0", "consola": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 52cf88f24..aa9b4d6bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2011,6 +2011,52 @@ __metadata: languageName: node linkType: hard +"@trpc/client@npm:^10.29.1": + version: 10.29.1 + resolution: "@trpc/client@npm:10.29.1" + peerDependencies: + "@trpc/server": 10.29.1 + checksum: b617edb56e9ec7fa0665703761c666244e58b8a9da5a2fffcdef880cdde7ec8c92c20d1362ac1c836904518d94db247ec286cc17e01fc4eea41b7cafbf3479fe + languageName: node + linkType: hard + +"@trpc/next@npm:^10.29.1": + version: 10.29.1 + resolution: "@trpc/next@npm:10.29.1" + dependencies: + react-ssr-prepass: ^1.5.0 + peerDependencies: + "@tanstack/react-query": ^4.18.0 + "@trpc/client": 10.29.1 + "@trpc/react-query": 10.29.1 + "@trpc/server": 10.29.1 + next: "*" + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 6afd6d7b1702eda5a26e7e8d2d381729fceb180952a2382314f08dd945b6e3204716c532b0ac6ddef10135816879c61dbe4b27aa88bcd98100890ede8d42fb25 + languageName: node + linkType: hard + +"@trpc/react-query@npm:^10.29.1": + version: 10.29.1 + resolution: "@trpc/react-query@npm:10.29.1" + peerDependencies: + "@tanstack/react-query": ^4.18.0 + "@trpc/client": 10.29.1 + "@trpc/server": 10.29.1 + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 3e52195eb894c0110b7fd99b0ab4fde38ade0b28d76c788d602c97789b76c66c91f678460fd68f3dc67c9b454340c7fe08363dfec69d4127982f9329bb42f503 + languageName: node + linkType: hard + +"@trpc/server@npm:^10.29.1": + version: 10.29.1 + resolution: "@trpc/server@npm:10.29.1" + checksum: d31ac9921764aea774ef14ae3d9d4c7cb245582dde7f75facd355732043eb9d697a8584c19377dce9acdf9b21f473917f77842fda29eb77fd604484e23b42ded + languageName: node + linkType: hard + "@tsconfig/node10@npm:^1.0.7": version: 1.0.9 resolution: "@tsconfig/node10@npm:1.0.9" @@ -5821,6 +5867,10 @@ __metadata: "@tanstack/react-query-persist-client": ^4.28.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^14.0.0 + "@trpc/client": ^10.29.1 + "@trpc/next": ^10.29.1 + "@trpc/react-query": ^10.29.1 + "@trpc/server": ^10.29.1 "@types/dockerode": ^3.3.9 "@types/node": 18.16.16 "@types/prismjs": ^1.26.0 @@ -8407,6 +8457,15 @@ __metadata: languageName: node linkType: hard +"react-ssr-prepass@npm:^1.5.0": + version: 1.5.0 + resolution: "react-ssr-prepass@npm:1.5.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: efe89b9f8b2053474613f56dfdbeb41d8ee2e572bf819a39377d95d3e4a9acf8a4a16e28d8d8034cb9ac2b316d11dc9e62217743e4322046d08175eb3b4fed3e + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.1": version: 2.2.1 resolution: "react-style-singleton@npm:2.2.1" From e490baae283c7f8e19fce31ea89a02c2fa52feb5 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 09:57:44 +0200 Subject: [PATCH 02/36] =?UTF-8?q?=F0=9F=94=A7=20Add=20import=20path=20~=20?= =?UTF-8?q?to=20tsconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tsconfig.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 922f76b10..1ca49efb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,10 @@ { "name": "next" } - ] + ], + "paths": { + "~/*": ["./src/*"] + }, }, "include": [ "next-env.d.ts", From 21044fb1c2c3ed01bd0dff7865f3ae2af38ea4ca Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 10:05:16 +0200 Subject: [PATCH 03/36] =?UTF-8?q?=E2=9C=A8=20Add=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/_app.tsx | 24 ++++++---- src/pages/api/trpc/[trpc].ts | 16 +++++++ src/server/api/root.ts | 11 +++++ src/server/api/trpc.ts | 93 ++++++++++++++++++++++++++++++++++++ src/utils/api.ts | 68 ++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 src/pages/api/trpc/[trpc].ts create mode 100644 src/server/api/root.ts create mode 100644 src/server/api/trpc.ts create mode 100644 src/utils/api.ts diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 36862b5ea..f61c290c8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -33,15 +33,17 @@ import { theme } from '../tools/server/theme/theme'; import { useEditModeInformationStore } from '../hooks/useEditModeInformation'; import '../styles/global.scss'; +import nextI18nextConfig from '../../next-i18next.config'; +import { api } from '~/utils/api'; function App( this: any, - props: AppProps & { + props: AppProps<{ colorScheme: ColorScheme; packageAttributes: ServerSidePackageAttributesType; editModeEnabled: boolean; defaultColorScheme: ColorScheme; - } + }> ) { const { Component, pageProps } = props; const [primaryColor, setPrimaryColor] = useState('red'); @@ -58,7 +60,7 @@ function App( // hook will return either 'dark' or 'light' on client // and always 'light' during ssr as window.matchMedia is not available - const preferredColorScheme = useColorScheme(props.defaultColorScheme); + const preferredColorScheme = useColorScheme(props.pageProps.defaultColorScheme); const [colorScheme, setColorScheme] = useLocalStorage({ key: 'mantine-color-scheme', defaultValue: preferredColorScheme, @@ -69,9 +71,9 @@ function App( const { setDisabled } = useEditModeInformationStore(); useEffect(() => { - setInitialPackageAttributes(props.packageAttributes); + setInitialPackageAttributes(props.pageProps.packageAttributes); - if (!props.editModeEnabled) { + if (!props.pageProps.editModeEnabled) { setDisabled(); } }, []); @@ -161,11 +163,13 @@ App.getInitialProps = ({ ctx }: { ctx: GetServerSidePropsContext }) => { const colorScheme: ColorScheme = (process.env.DEFAULT_COLOR_SCHEME as ColorScheme) ?? 'light'; return { - colorScheme: getCookie('color-scheme', ctx) || 'light', - packageAttributes: getServiceSidePackageAttributes(), - editModeEnabled: !disableEditMode, - defaultColorScheme: colorScheme, + pageProps: { + colorScheme: getCookie('color-scheme', ctx) || 'light', + packageAttributes: getServiceSidePackageAttributes(), + editModeEnabled: !disableEditMode, + defaultColorScheme: colorScheme, + }, }; }; -export default appWithTranslation(App); +export default appWithTranslation(api.withTRPC(App), nextI18nextConfig as any); diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts new file mode 100644 index 000000000..67c9b4899 --- /dev/null +++ b/src/pages/api/trpc/[trpc].ts @@ -0,0 +1,16 @@ +import { createNextApiHandler } from '@trpc/server/adapters/next'; +import Consola from 'consola'; +import { createTRPCContext } from '~/server/api/trpc'; +import { appRouter } from '~/server/api/root'; + +// export API handler +export default createNextApiHandler({ + router: appRouter, + createContext: createTRPCContext, + onError: + process.env.NODE_ENV === 'development' + ? ({ path, error }) => { + Consola.error(`❌ tRPC failed on ${path ?? ''}: ${error.message}`); + } + : undefined, +}); diff --git a/src/server/api/root.ts b/src/server/api/root.ts new file mode 100644 index 000000000..57f7f9f98 --- /dev/null +++ b/src/server/api/root.ts @@ -0,0 +1,11 @@ +import { createTRPCRouter } from '~/server/api/trpc'; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ +export const rootRouter = createTRPCRouter({}); + +// export type definition of API +export type RootRouter = typeof rootRouter; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts new file mode 100644 index 000000000..748edda91 --- /dev/null +++ b/src/server/api/trpc.ts @@ -0,0 +1,93 @@ +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ + +import { initTRPC } from '@trpc/server'; +import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + */ + +type CreateContextOptions = Record; + +/** + * This helper generates the "internals" for a tRPC context. If you need to use it, you can export + * it from here. + * + * Examples of things you may need it for: + * - testing, so we don't have to mock Next.js' req/res + * - tRPC's `createSSGHelpers`, where we don't have req/res + * + * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts + */ +const createInnerTRPCContext = (opts: CreateContextOptions) => ({}); + +/** + * This is the actual context you will use in your router. It will be used to process every request + * that goes through your tRPC endpoint. + * + * @see https://trpc.io/docs/context + */ +export const createTRPCContext = async (opts: CreateNextContextOptions) => { + const { req, res } = opts; + + // Get the session from the server using the getServerSession wrapper function + + return createInnerTRPCContext({}); +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ + +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +export const createTRPCRouter = t.router; + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +export const publicProcedure = t.procedure; diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 000000000..9181abda0 --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,68 @@ +/** + * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which + * contains the Next.js App-wrapper, as well as your type-safe React Query hooks. + * + * We also create a few inference helpers for input and output types. + */ +import { httpBatchLink, loggerLink } from '@trpc/client'; +import { createTRPCNext } from '@trpc/next'; +import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; +import superjson from 'superjson'; + +import { type RootRouter } from '~/server/api/root'; + +const getBaseUrl = () => { + if (typeof window !== 'undefined') return ''; // browser should use relative url + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url + return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost +}; + +/** A set of type-safe react-query hooks for your tRPC API. */ +export const api = createTRPCNext({ + config() { + return { + /** + * Transformer used for data de-serialization from the server. + * + * @see https://trpc.io/docs/data-transformers + */ + transformer: superjson, + + /** + * Links used to determine request flow from client to server. + * + * @see https://trpc.io/docs/links + */ + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === 'development' || + (opts.direction === 'down' && opts.result instanceof Error), + }), + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + ], + }; + }, + /** + * Whether tRPC should await queries when server rendering pages. + * + * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false + */ + ssr: false, +}); + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +export type RouterOutputs = inferRouterOutputs; From 8e14e323a7cc63f95cc52b724e7fbddb5a904212 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 10:23:54 +0200 Subject: [PATCH 04/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20ping=20?= =?UTF-8?q?to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/Tiles/Apps/AppPing.tsx | 47 +++++++++++-------- src/modules/index.ts | 1 - src/server/api/root.ts | 5 +- src/server/api/routers/app.ts | 46 ++++++++++++++++++ 4 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 src/server/api/routers/app.ts diff --git a/src/components/Dashboard/Tiles/Apps/AppPing.tsx b/src/components/Dashboard/Tiles/Apps/AppPing.tsx index b2e3bd559..5af4b0938 100644 --- a/src/components/Dashboard/Tiles/Apps/AppPing.tsx +++ b/src/components/Dashboard/Tiles/Apps/AppPing.tsx @@ -1,10 +1,10 @@ import { Indicator, Tooltip } from '@mantine/core'; import Consola from 'consola'; -import { useQuery } from '@tanstack/react-query'; import { motion } from 'framer-motion'; import { useTranslation } from 'next-i18next'; import { useConfigContext } from '../../../../config/provider'; import { AppType } from '../../../../types/app'; +import { api } from '~/utils/api'; interface AppPingProps { app: AppType; @@ -16,18 +16,7 @@ export const AppPing = ({ app }: AppPingProps) => { const active = (config?.settings.customization.layout.enabledPing && app.network.enabledStatusChecker) ?? false; - const { data, isLoading } = useQuery({ - queryKey: ['ping', { id: app.id, name: app.name }], - queryFn: async () => { - const response = await fetch(`/api/modules/ping?url=${encodeURI(app.url)}`); - const isOk = getIsOk(app, response.status); - return { - status: response.status, - state: isOk ? 'online' : 'down', - }; - }, - enabled: active, - }); + const { data, isLoading, error } = usePingQuery(app, active); const isOnline = data?.state === 'online'; @@ -49,7 +38,7 @@ export const AppPing = ({ app }: AppPingProps) => { ? t('states.loading') : isOnline ? t('states.online', { response: data.status }) - : t('states.offline', { response: data?.status }) + : t('states.offline', { response: data?.status ?? error?.data?.httpStatus }) } > { ); }; +const usePingQuery = (app: AppType, isEnabled: boolean) => + api.app.ping.useQuery( + { + url: app.url, + }, + { + enabled: isEnabled, + select: (data) => { + const statusCode = data.status; + const isOk = getIsOk(app, statusCode); + return { + status: statusCode, + state: isOk ? ('online' as const) : ('down' as const), + }; + }, + } + ); + const getIsOk = (app: AppType, status: number) => { -if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) { -Consola.log('Using new status codes'); -return app.network.statusCodes.includes(status.toString()); -} -Consola.warn('Using deprecated okStatus'); -return app.network.okStatus.includes(status); + if (app.network.okStatus === undefined || app.network.statusCodes.length >= 1) { + Consola.log('Using new status codes'); + return app.network.statusCodes.includes(status.toString()); + } + Consola.warn('Using deprecated okStatus'); + return app.network.okStatus.includes(status); }; diff --git a/src/modules/index.ts b/src/modules/index.ts index 3f69cd0de..54bebdbc1 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -1,2 +1 @@ -export * from './ping'; export * from './overseerr'; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 57f7f9f98..cf28aaa48 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,11 +1,14 @@ import { createTRPCRouter } from '~/server/api/trpc'; +import { appRouter } from './routers/app'; /** * This is the primary router for your server. * * All routers added in /api/routers should be manually added here. */ -export const rootRouter = createTRPCRouter({}); +export const rootRouter = createTRPCRouter({ + app: appRouter, +}); // export type definition of API export type RootRouter = typeof rootRouter; diff --git a/src/server/api/routers/app.ts b/src/server/api/routers/app.ts new file mode 100644 index 000000000..184e4989e --- /dev/null +++ b/src/server/api/routers/app.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import axios, { AxiosError } from 'axios'; +import https from 'https'; +import Consola from 'consola'; +import { TRPCError } from '@trpc/server'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const appRouter = createTRPCRouter({ + ping: publicProcedure + .input( + z.object({ + url: z.string(), + }) + ) + .query(async ({ input }) => { + const agent = new https.Agent({ rejectUnauthorized: false }); + const res = await axios + .get(input.url, { httpsAgent: agent, timeout: 2000 }) + .then((response) => ({ + status: response.status, + statusText: response.statusText, + })) + .catch((error: AxiosError) => { + if (error.response) { + Consola.warn(`Unexpected response: ${error.message}`); + return { + status: error.response.status, + statusText: error.response.statusText, + }; + } + if (error.code === 'ECONNABORTED') { + throw new TRPCError({ + code: 'TIMEOUT', + message: 'Request Timeout', + }); + } + + Consola.error(`Unexpected error: ${error.message}`); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Internal Server Error', + }); + }); + return res; + }), +}); From 34e0fc56beae283e9afb11584c61963750c331b6 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 10:24:13 +0200 Subject: [PATCH 05/36] =?UTF-8?q?=E2=9A=B0=EF=B8=8F=20Remove=20unused=20pi?= =?UTF-8?q?ng=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/ping/PingModule.tsx | 89 --------------------------------- src/modules/ping/index.ts | 1 - 2 files changed, 90 deletions(-) delete mode 100644 src/modules/ping/PingModule.tsx delete mode 100644 src/modules/ping/index.ts diff --git a/src/modules/ping/PingModule.tsx b/src/modules/ping/PingModule.tsx deleted file mode 100644 index 9094d913d..000000000 --- a/src/modules/ping/PingModule.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Indicator, Tooltip } from '@mantine/core'; -import { IconPlug as Plug } from '@tabler/icons-react'; -import axios, { AxiosResponse } from 'axios'; -import { motion } from 'framer-motion'; -import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { useConfigContext } from '../../config/provider'; -import { IModule } from '../ModuleTypes'; - -export const PingModule: IModule = { - title: 'Ping Services', - icon: Plug, - component: PingComponent, - id: 'ping', -}; - -export default function PingComponent(props: any) { - type State = 'loading' | 'down' | 'online'; - const { config } = useConfigContext(); - - const { url }: { url: string } = props; - const [isOnline, setOnline] = useState('loading'); - const [response, setResponse] = useState(500); - const exists = config?.settings.customization.layout.enabledPing || false; - - const { t } = useTranslation('modules/ping'); - - function statusCheck(response: AxiosResponse) { - const { status }: { status: string[] } = props; - //Default Status - let acceptableStatus = ['200']; - if (status !== undefined && status.length) { - acceptableStatus = status; - } - // Checks if reported status is in acceptable status array - if (acceptableStatus.indexOf(response.status.toString()) >= 0) { - setOnline('online'); - setResponse(response.status); - } else { - setOnline('down'); - setResponse(response.status); - } - } - - useEffect(() => { - if (!exists) { - return; - } - axios - .get('/api/modules/ping', { params: { url } }) - .then((response) => { - statusCheck(response); - }) - .catch((error) => { - statusCheck(error.response); - }); - }, [config?.settings.customization.layout.enabledPing]); - if (!exists) { - return null; - } - return ( - - - - {null} - - - - ); -} diff --git a/src/modules/ping/index.ts b/src/modules/ping/index.ts deleted file mode 100644 index f4de8c000..000000000 --- a/src/modules/ping/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PingModule } from './PingModule'; From d89e9fb36d64b143a2f54664fa141b8ce2cc12ac Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 11:06:50 +0200 Subject: [PATCH 06/36] =?UTF-8?q?=F0=9F=90=9B=20Fix=20issue=20with=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/api/trpc/[trpc].ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/api/trpc/[trpc].ts b/src/pages/api/trpc/[trpc].ts index 67c9b4899..4779613ba 100644 --- a/src/pages/api/trpc/[trpc].ts +++ b/src/pages/api/trpc/[trpc].ts @@ -1,11 +1,11 @@ import { createNextApiHandler } from '@trpc/server/adapters/next'; import Consola from 'consola'; import { createTRPCContext } from '~/server/api/trpc'; -import { appRouter } from '~/server/api/root'; +import { rootRouter } from '~/server/api/root'; // export API handler export default createNextApiHandler({ - router: appRouter, + router: rootRouter, createContext: createTRPCContext, onError: process.env.NODE_ENV === 'development' From dc5bcbe9b26fade35a6e73eb285c942f9ef85c7f Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 11:07:49 +0200 Subject: [PATCH 07/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20rss=20t?= =?UTF-8?q?o=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/api/root.ts | 2 + src/server/api/routers/rss.ts | 185 ++++++++++++++++++++++++++++++ src/widgets/rss/RssWidgetTile.tsx | 83 ++++++++------ 3 files changed, 236 insertions(+), 34 deletions(-) create mode 100644 src/server/api/routers/rss.ts diff --git a/src/server/api/root.ts b/src/server/api/root.ts index cf28aaa48..2f8a5e08b 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,5 +1,6 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { appRouter } from './routers/app'; +import { rssRouter } from './routers/rss'; /** * This is the primary router for your server. @@ -8,6 +9,7 @@ import { appRouter } from './routers/app'; */ export const rootRouter = createTRPCRouter({ app: appRouter, + rss: rssRouter, }); // export type definition of API diff --git a/src/server/api/routers/rss.ts b/src/server/api/routers/rss.ts new file mode 100644 index 000000000..e60c2dd1a --- /dev/null +++ b/src/server/api/routers/rss.ts @@ -0,0 +1,185 @@ +import { z } from 'zod'; +import RssParser from 'rss-parser'; +import Consola from 'consola'; +import { decode, encode } from 'html-entities'; +import xss from 'xss'; +import { TRPCError } from '@trpc/server'; +import { createTRPCRouter, publicProcedure } from '../trpc'; +import { Stopwatch } from '~/tools/shared/time/stopwatch.tool'; +import { getConfig } from '~/tools/config/getConfig'; +import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; + +type CustomItem = { + 'media:content': string; + enclosure: { + url: string; + }; +}; + +const rssFeedResultObjectSchema = z + .object({ + success: z.literal(false), + feed: z.undefined(), + }) + .or( + z.object({ + success: z.literal(true), + feed: z.object({ + title: z.string().or(z.undefined()), + items: z.array( + z.object({ + link: z.string(), + enclosure: z + .object({ + url: z.string(), + }) + .or(z.undefined()), + categories: z.array(z.string()).or(z.undefined()), + title: z.string(), + content: z.string(), + pubDate: z.string(), + }) + ), + }), + }) + ); + +export const rssRouter = createTRPCRouter({ + all: publicProcedure + .input( + z.object({ + widgetId: z.string().uuid(), + feedUrls: z.array(z.string()), + configName: z.string(), + }) + ) + .output(z.array(rssFeedResultObjectSchema)) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const rssWidget = config.widgets.find((x) => x.type === 'rss' && x.id === input.widgetId) as + | IRssWidget + | undefined; + + if (!rssWidget || input.feedUrls.length === 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'required widget does not exist', + }); + } + + const result = await Promise.all( + input.feedUrls.map(async (feedUrl) => + getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent) + ) + ); + return result; + }), +}); + +const getFeedUrl = async (feedUrl: string, dangerousAllowSanitizedItemContent: boolean) => { + Consola.info(`Requesting RSS feed at url ${feedUrl}`); + const stopWatch = new Stopwatch(); + const feed = await parser.parseURL(feedUrl); + Consola.info(`Retrieved RSS feed after ${stopWatch.getEllapsedMilliseconds()} milliseconds`); + + const orderedFeed = { + ...feed, + items: feed.items + .map( + (item: { + title: string; + content: string; + 'content:encoded': string; + categories: string[] | { _: string }[]; + }) => ({ + ...item, + categories: item.categories + ?.map((category) => (typeof category === 'string' ? category : category._)) + .filter((category: unknown): category is string => typeof category === 'string'), + title: item.title ? decode(item.title) : undefined, + content: processItemContent( + item['content:encoded'] ?? item.content, + dangerousAllowSanitizedItemContent + ), + enclosure: createEnclosure(item), + link: createLink(item), + }) + ) + .sort((a: { pubDate: number }, b: { pubDate: number }) => { + if (!a.pubDate || !b.pubDate) { + return 0; + } + + return a.pubDate - b.pubDate; + }) + .slice(0, 20), + }; + + return { + feed: orderedFeed, + success: orderedFeed?.items !== undefined, + }; +}; + +const processItemContent = (content: string, dangerousAllowSanitizedItemContent: boolean) => { + if (dangerousAllowSanitizedItemContent) { + return xss(content, { + allowList: { + p: [], + h1: [], + h2: [], + h3: [], + h4: [], + h5: [], + h6: [], + a: ['href'], + b: [], + strong: [], + i: [], + em: [], + img: ['src', 'width', 'height'], + br: [], + small: [], + ul: [], + li: [], + ol: [], + figure: [], + svg: [], + code: [], + mark: [], + blockquote: [], + }, + }); + } + + return encode(content); +}; + +const createLink = (item: any) => { + if (item.link) { + return item.link; + } + + return item.guid; +}; + +const createEnclosure = (item: any) => { + if (item.enclosure) { + return item.enclosure; + } + + if (item['media:content']) { + return { + url: item['media:content'].$.url, + }; + } + + return undefined; +}; + +const parser: RssParser = new RssParser({ + customFields: { + item: ['media:content', 'enclosure'], + }, +}); diff --git a/src/widgets/rss/RssWidgetTile.tsx b/src/widgets/rss/RssWidgetTile.tsx index dc1cebe68..9512bc2ef 100644 --- a/src/widgets/rss/RssWidgetTile.tsx +++ b/src/widgets/rss/RssWidgetTile.tsx @@ -15,11 +15,12 @@ import { createStyles, } from '@mantine/core'; import { IconClock, IconRefresh, IconRss } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { useTranslation } from 'next-i18next'; import Link from 'next/link'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; @@ -65,27 +66,11 @@ interface RssTileProps { widget: IRssWidget; } -export const useGetRssFeeds = (feedUrls: string[], refreshInterval: number, widgetId: string) => - useQuery({ - queryKey: ['rss-feeds', feedUrls], - // Cache the results for 24 hours - cacheTime: 1000 * 60 * 60 * 24, - staleTime: 1000 * 60 * refreshInterval, - queryFn: async () => { - const responses = await Promise.all( - feedUrls.map((feedUrl) => - fetch( - `/api/modules/rss?widgetId=${widgetId}&feedUrl=${encodeURIComponent(feedUrl)}` - ).then((response) => response.json()) - ) - ); - return responses; - }, - }); - function RssTile({ widget }: RssTileProps) { const { t } = useTranslation('modules/rss'); + const { name: configName } = useConfigContext(); const { data, isLoading, isFetching, isError, refetch } = useGetRssFeeds( + configName, widget.properties.rssFeedUrl, widget.properties.refreshInterval, widget.id @@ -122,6 +107,7 @@ function RssTile({ widget }: RssTileProps) { {t('descriptor.card.errors.general.title')} {t('descriptor.card.errors.general.text')} + ); } @@ -192,25 +178,54 @@ function RssTile({ widget }: RssTileProps) { ))} - refetch()} - bottom={10} - styles={{ - root: { - borderColor: 'red', - }, - }} - > - {isFetching ? : } - + ); } +export const useGetRssFeeds = ( + configName: string | undefined, + feedUrls: string[], + refreshInterval: number, + widgetId: string +) => + api.rss.all.useQuery( + { + configName: configName ?? '', + feedUrls, + widgetId, + }, + { + // Cache the results for 24 hours + cacheTime: 1000 * 60 * 60 * 24, + staleTime: 1000 * 60 * refreshInterval, + enabled: !!configName, + } + ); + +interface RefetchButtonProps { + refetch: () => void; + isFetching: boolean; +} + +const RefetchButton = ({ isFetching, refetch }: RefetchButtonProps) => ( + refetch()} + bottom={10} + styles={{ + root: { + borderColor: 'red', + }, + }} + > + {isFetching ? : } + +); + const InfoDisplay = ({ date, title }: { date: string; title: string | undefined }) => ( From 62cfce4ba165e784ee536735cefe33c2fd356e80 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 11:28:35 +0200 Subject: [PATCH 08/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20config?= =?UTF-8?q?=20list=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Config/ConfigChanger.tsx | 14 ++++---------- src/server/api/root.ts | 2 ++ src/server/api/routers/config.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/server/api/routers/config.ts diff --git a/src/components/Config/ConfigChanger.tsx b/src/components/Config/ConfigChanger.tsx index 4826fcdd8..19a88b179 100644 --- a/src/components/Config/ConfigChanger.tsx +++ b/src/components/Config/ConfigChanger.tsx @@ -1,12 +1,12 @@ import { Center, Dialog, Loader, Notification, Select, Tooltip } from '@mantine/core'; import { useToggle } from '@mantine/hooks'; -import { useQuery } from '@tanstack/react-query'; +import { notifications } from '@mantine/notifications'; +import { IconCheck } from '@tabler/icons-react'; import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { useState } from 'react'; -import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons-react'; +import { api } from '~/utils/api'; import { useConfigContext } from '../../config/provider'; export default function ConfigChanger() { @@ -95,10 +95,4 @@ export default function ConfigChanger() { ); } -const useConfigsQuery = () => - useQuery({ - queryKey: ['config/get-all'], - queryFn: fetchConfigs, - }); - -const fetchConfigs = async () => (await (await fetch('/api/configs')).json()) as string[]; +const useConfigsQuery = () => api.config.all.useQuery(); diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2f8a5e08b..38b89870f 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from '~/server/api/trpc'; import { appRouter } from './routers/app'; import { rssRouter } from './routers/rss'; +import { configRouter } from './routers/config'; /** * This is the primary router for your server. @@ -10,6 +11,7 @@ import { rssRouter } from './routers/rss'; export const rootRouter = createTRPCRouter({ app: appRouter, rss: rssRouter, + config: configRouter, }); // export type definition of API diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts new file mode 100644 index 000000000..d695f7e7f --- /dev/null +++ b/src/server/api/routers/config.ts @@ -0,0 +1,12 @@ +import fs from 'fs'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const configRouter = createTRPCRouter({ + all: publicProcedure.query(async () => { + // Get all the configs in the /data/configs folder + // All the files that end in ".json" + const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); + // Strip the .json extension from the file name + return files.map((file) => file.replace('.json', '')); + }), +}); From fc298918b23616fa3841e972d2dcfcc9c8bb2c13 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 11:46:51 +0200 Subject: [PATCH 09/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20config?= =?UTF-8?q?=20deletion=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Common/Config/ConfigActions.tsx | 81 +++++++++++++------ src/server/api/routers/config.ts | 47 +++++++++++ .../mutations/useDeleteConfigMutation.tsx | 26 ------ 3 files changed, 102 insertions(+), 52 deletions(-) delete mode 100644 src/tools/config/mutations/useDeleteConfigMutation.tsx diff --git a/src/components/Settings/Common/Config/ConfigActions.tsx b/src/components/Settings/Common/Config/ConfigActions.tsx index 2cce6ac20..700ccd70d 100644 --- a/src/components/Settings/Common/Config/ConfigActions.tsx +++ b/src/components/Settings/Common/Config/ConfigActions.tsx @@ -10,23 +10,28 @@ import { import { useDisclosure } from '@mantine/hooks'; import { openConfirmModal } from '@mantine/modals'; import { showNotification } from '@mantine/notifications'; -import { IconAlertTriangle, IconCheck, IconCopy, IconDownload, IconTrash } from '@tabler/icons-react'; +import { + IconAlertTriangle, + IconCheck, + IconCopy, + IconDownload, + IconTrash, + IconX, +} from '@tabler/icons-react'; import fileDownload from 'js-file-download'; import { Trans, useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; import { useConfigContext } from '../../../../config/provider'; import { useConfigStore } from '../../../../config/store'; -import { useDeleteConfigMutation } from '../../../../tools/config/mutations/useDeleteConfigMutation'; import Tip from '../../../layout/Tip'; import { CreateConfigCopyModal } from './CreateCopyModal'; +import { api } from '~/utils/api'; export default function ConfigActions() { - const router = useRouter(); const { t } = useTranslation(['settings/general/config-changer', 'settings/common', 'common']); const [createCopyModalOpened, createCopyModal] = useDisclosure(false); const { config } = useConfigContext(); - const { removeConfig } = useConfigStore(); - const { mutateAsync } = useDeleteConfigMutation(config?.configProperties.name ?? 'default'); + const { mutateAsync } = useDeleteConfigMutation(); if (!config) return null; @@ -61,28 +66,9 @@ export default function ConfigActions() { }, zIndex: 201, onConfirm: async () => { - const response = await mutateAsync(); - - if (response.error) { - showNotification({ - title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'), - message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'), - }); - return; - } - - showNotification({ - title: t('buttons.delete.notifications.deleted.title'), - icon: , - color: 'green', - autoClose: 1500, - radius: 'md', - message: t('buttons.delete.notifications.deleted.message'), + const response = await mutateAsync({ + name: config?.configProperties.name ?? 'default', }); - - removeConfig(config?.configProperties.name ?? 'default'); - - router.push('/'); }, }); }; @@ -124,6 +110,49 @@ export default function ConfigActions() { ); } +const useDeleteConfigMutation = () => { + const { t } = useTranslation(['settings/general/config-changer']); + const router = useRouter(); + const { removeConfig } = useConfigStore(); + + return api.config.delete.useMutation({ + onError(error) { + if (error.data?.code === 'FORBIDDEN') { + showNotification({ + title: t('buttons.delete.notifications.deleteFailedDefaultConfig.title'), + icon: , + color: 'red', + autoClose: 1500, + radius: 'md', + message: t('buttons.delete.notifications.deleteFailedDefaultConfig.message'), + }); + } + showNotification({ + title: t('buttons.delete.notifications.deleteFailed.title'), + icon: , + color: 'red', + autoClose: 1500, + radius: 'md', + message: t('buttons.delete.notifications.deleteFailed.message'), + }); + }, + onSuccess(data, variables) { + showNotification({ + title: t('buttons.delete.notifications.deleted.title'), + icon: , + color: 'green', + autoClose: 1500, + radius: 'md', + message: t('buttons.delete.notifications.deleted.message'), + }); + + removeConfig(variables.name); + + router.push('/'); + }, + }); +}; + const useStyles = createStyles(() => ({ actionIcon: { width: 'auto', diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index d695f7e7f..5e9e24d1a 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -1,4 +1,8 @@ import fs from 'fs'; +import path from 'path'; +import Consola from 'consola'; +import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; import { createTRPCRouter, publicProcedure } from '../trpc'; export const configRouter = createTRPCRouter({ @@ -9,4 +13,47 @@ export const configRouter = createTRPCRouter({ // Strip the .json extension from the file name return files.map((file) => file.replace('.json', '')); }), + delete: publicProcedure + .input( + z.object({ + name: z.string(), + }) + ) + .mutation(async ({ input }) => { + if (input.name.toLowerCase() === 'default') { + Consola.error("Rejected config deletion because default configuration can't be deleted"); + throw new TRPCError({ + code: 'FORBIDDEN', + message: "Default config can't be deleted", + }); + } + + // Loop over all the files in the /data/configs directory + // Get all the configs in the /data/configs folder + // All the files that end in ".json" + const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json')); + // Match one file if the configProperties.name is the same as the slug + const matchedFile = files.find((file) => { + const config = JSON.parse(fs.readFileSync(path.join('data/configs', file), 'utf8')); + return config.configProperties.name === input.name; + }); + + // If the target is not in the list of files, return an error + if (!matchedFile) { + Consola.error( + `Rejected config deletion request because config name '${input.name}' was not included in present configurations` + ); + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Target not found', + }); + } + + // Delete the file + fs.unlinkSync(path.join('data/configs', matchedFile)); + Consola.info(`Successfully deleted configuration '${input.name}' from your file system`); + return { + message: 'Configuration deleted with success', + }; + }), }); diff --git a/src/tools/config/mutations/useDeleteConfigMutation.tsx b/src/tools/config/mutations/useDeleteConfigMutation.tsx deleted file mode 100644 index 2350869b5..000000000 --- a/src/tools/config/mutations/useDeleteConfigMutation.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { showNotification } from '@mantine/notifications'; -import { IconX } from '@tabler/icons-react'; -import { useMutation } from '@tanstack/react-query'; -import { useTranslation } from 'next-i18next'; - -export const useDeleteConfigMutation = (configName: string) => { - const { t } = useTranslation(['settings/general/config-changer']); - - return useMutation({ - mutationKey: ['configs/delete', { configName }], - mutationFn: () => fetchDeletion(configName), - onError() { - showNotification({ - title: t('buttons.delete.notifications.deleteFailed.title'), - icon: , - color: 'red', - autoClose: 1500, - radius: 'md', - message: t('buttons.delete.notifications.deleteFailed.message'), - }); - }, - }); -}; - -const fetchDeletion = async (configName: string) => - (await fetch(`/api/configs/${configName}`, { method: 'DELETE' })).json(); From 0d2bbce8d75ef252bf62ed9c113f9e54060660f5 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 12:24:16 +0200 Subject: [PATCH 10/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20config?= =?UTF-8?q?=20save=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Config/LoadConfig.tsx | 58 ++++++---- .../Common/Config/CreateCopyModal.tsx | 50 ++++++++- .../Actions/ToggleEditMode/ToggleEditMode.tsx | 10 +- src/config/store.ts | 19 ++-- src/server/api/routers/config.ts | 103 ++++++++++++++++++ src/utils/api.ts | 57 +++++----- 6 files changed, 231 insertions(+), 66 deletions(-) diff --git a/src/components/Config/LoadConfig.tsx b/src/components/Config/LoadConfig.tsx index dfa31f7cd..d9c64c558 100644 --- a/src/components/Config/LoadConfig.tsx +++ b/src/components/Config/LoadConfig.tsx @@ -4,18 +4,20 @@ import { showNotification } from '@mantine/notifications'; import { IconCheck as Check, IconPhoto, IconUpload, IconX, IconX as X } from '@tabler/icons-react'; import { setCookie } from 'cookies-next'; import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; import { useConfigStore } from '../../config/store'; import { ConfigType } from '../../types/config'; +import { api } from '~/utils/api'; export const LoadConfigComponent = () => { - const { addConfig } = useConfigStore(); const theme = useMantineTheme(); const { t } = useTranslation('settings/general/config-changer'); + const { mutateAsync: loadAsync } = useLoadConfig(); return ( { - const fileName = files[0].name.replaceAll('.json', ''); + const configName = files[0].name.replaceAll('.json', ''); const fileText = await files[0].text(); try { @@ -32,26 +34,7 @@ export const LoadConfigComponent = () => { } const newConfig: ConfigType = JSON.parse(fileText); - - await addConfig(fileName, newConfig, true); - showNotification({ - autoClose: 5000, - radius: 'md', - title: ( - - {t('dropzone.notifications.loadedSuccessfully.title', { - configName: fileName, - })} - - ), - color: 'green', - icon: , - message: undefined, - }); - setCookie('config-name', fileName, { - maxAge: 60 * 60 * 24 * 30, - sameSite: 'strict', - }); + await loadAsync({ name: configName, config: newConfig }); }} accept={['application/json']} > @@ -89,3 +72,34 @@ export const LoadConfigComponent = () => { ); }; + +const useLoadConfig = () => { + const { t } = useTranslation('settings/general/config-changer'); + const { addConfig } = useConfigStore(); + const router = useRouter(); + return api.config.save.useMutation({ + async onSuccess(_data, variables) { + await addConfig(variables.name, variables.config); + + showNotification({ + autoClose: 5000, + radius: 'md', + title: ( + + {t('dropzone.notifications.loadedSuccessfully.title', { + configName: variables.name, + })} + + ), + color: 'green', + icon: , + message: undefined, + }); + setCookie('config-name', variables.name, { + maxAge: 60 * 60 * 24 * 30, + sameSite: 'strict', + }); + router.push(`/${variables.name}`); + }, + }); +}; diff --git a/src/components/Settings/Common/Config/CreateCopyModal.tsx b/src/components/Settings/Common/Config/CreateCopyModal.tsx index fb924f804..bf1409d1f 100644 --- a/src/components/Settings/Common/Config/CreateCopyModal.tsx +++ b/src/components/Settings/Common/Config/CreateCopyModal.tsx @@ -1,8 +1,11 @@ import { Button, Group, Modal, TextInput, Title } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useTranslation } from 'next-i18next'; +import { IconCheck, IconX } from '@tabler/icons-react'; +import { showNotification } from '@mantine/notifications'; import { useConfigStore } from '../../../../config/store'; -import { useCopyConfigMutation } from '../../../../tools/config/mutations/useCopyConfigMutation'; +import { useConfigContext } from '~/config/provider'; +import { api } from '~/utils/api'; interface CreateConfigCopyModalProps { opened: boolean; @@ -16,6 +19,7 @@ export const CreateConfigCopyModal = ({ initialConfigName, }: CreateConfigCopyModalProps) => { const { configs } = useConfigStore(); + const { config } = useConfigContext(); const { t } = useTranslation(['settings/general/config-changer']); const form = useForm({ @@ -40,7 +44,7 @@ export const CreateConfigCopyModal = ({ validateInputOnBlur: true, }); - const { mutateAsync } = useCopyConfigMutation(form.values.configName); + const { mutateAsync } = useCopyConfigMutation(); const handleClose = () => { form.setFieldValue('configName', initialConfigName); @@ -50,7 +54,17 @@ export const CreateConfigCopyModal = ({ const handleSubmit = async (values: typeof form.values) => { if (!form.isValid) return; - await mutateAsync(); + if (!config) { + throw new Error('config is not defiend'); + } + + const copiedConfig = config; + copiedConfig.configProperties.name = form.values.configName; + + await mutateAsync({ + name: form.values.configName, + config: copiedConfig, + }); closeModal(); }; @@ -76,3 +90,33 @@ export const CreateConfigCopyModal = ({ ); }; + +const useCopyConfigMutation = () => { + const { t } = useTranslation(['settings/general/config-changer']); + const utils = api.useContext(); + + return api.config.save.useMutation({ + onSuccess(_data, variables) { + showNotification({ + title: t('modal.copy.events.configCopied.title'), + icon: , + color: 'green', + autoClose: 1500, + radius: 'md', + message: t('modal.copy.events.configCopied.message', { configName: variables.name }), + }); + // Invalidate a query to fetch new config + utils.config.all.invalidate(); + }, + onError(_error, variables) { + showNotification({ + title: t('modal.events.configNotCopied.title'), + icon: , + color: 'red', + autoClose: 1500, + radius: 'md', + message: t('modal.events.configNotCopied.message', { configName: variables.name }), + }); + }, + }); +}; diff --git a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx b/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx index 481e61f42..3cbe17e15 100644 --- a/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx +++ b/src/components/layout/header/Actions/ToggleEditMode/ToggleEditMode.tsx @@ -2,13 +2,13 @@ import { ActionIcon, Button, Group, Text, Title, Tooltip } from '@mantine/core'; import { useHotkeys, useWindowEvent } from '@mantine/hooks'; import { hideNotification, showNotification } from '@mantine/notifications'; import { IconEditCircle, IconEditCircleOff } from '@tabler/icons-react'; -import axios from 'axios'; import Consola from 'consola'; import { getCookie } from 'cookies-next'; import { Trans, useTranslation } from 'next-i18next'; import { useConfigContext } from '../../../../../config/provider'; import { useScreenSmallerThan } from '../../../../../hooks/useScreenSmallerThan'; +import { api } from '~/utils/api'; import { useEditModeStore } from '../../../../Dashboard/Views/useEditModeStore'; import { useNamedWrapperColumnCount } from '../../../../Dashboard/Wrappers/gridstack/store'; import { useCardStyles } from '../../../useCardStyles'; @@ -28,6 +28,7 @@ export const ToggleEditModeAction = () => { const smallerThanSm = useScreenSmallerThan('sm'); const { config } = useConfigContext(); const { classes } = useCardStyles(true); + const { mutateAsync: saveConfig } = api.config.save.useMutation(); useHotkeys([['mod+E', toggleEditMode]]); @@ -41,11 +42,12 @@ export const ToggleEditModeAction = () => { return undefined; }); - const toggleButtonClicked = () => { + const toggleButtonClicked = async () => { toggleEditMode(); - if (enabled || config === undefined || config?.schemaVersion === undefined) { + if (config === undefined || config?.schemaVersion === undefined) return; + if (enabled) { const configName = getCookie('config-name')?.toString() ?? 'default'; - axios.put(`/api/configs/${configName}`, { ...config }); + await saveConfig({ name: configName, config }); Consola.log('Saved config to server', configName); hideNotification('toggle-edit-mode'); } else if (!enabled) { diff --git a/src/config/store.ts b/src/config/store.ts index bca63e0b7..d4558ad22 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import { create } from 'zustand'; import { ConfigType } from '../types/config'; +import { api, trcpProxyClient } from '~/utils/api'; export const useConfigStore = create((set, get) => ({ configs: [], @@ -13,7 +14,7 @@ export const useConfigStore = create((set, get) => ({ ], })); }, - addConfig: async (name: string, config: ConfigType, shouldSaveConfigToFileSystem = true) => { + addConfig: async (name: string, config: ConfigType) => { set((old) => ({ ...old, configs: [ @@ -21,11 +22,6 @@ export const useConfigStore = create((set, get) => ({ { value: config, increaseVersion: () => {} }, ], })); - - if (!shouldSaveConfigToFileSystem) { - return; - } - axios.put(`/api/configs/${name}`, { ...config }); }, removeConfig: (name: string) => { set((old) => ({ @@ -66,7 +62,10 @@ export const useConfigStore = create((set, get) => ({ } if (shouldSaveConfigToFileSystem) { - axios.put(`/api/configs/${name}`, { ...updatedConfig }); + trcpProxyClient.config.save.mutate({ + name, + config: updatedConfig, + }); } }, })); @@ -74,11 +73,7 @@ export const useConfigStore = create((set, get) => ({ interface UseConfigStoreType { configs: { increaseVersion: () => void; value: ConfigType }[]; initConfig: (name: string, config: ConfigType, increaseVersion: () => void) => void; - addConfig: ( - name: string, - config: ConfigType, - shouldSaveConfigToFileSystem: boolean - ) => Promise; + addConfig: (name: string, config: ConfigType) => Promise; removeConfig: (name: string) => void; updateConfig: ( name: string, diff --git a/src/server/api/routers/config.ts b/src/server/api/routers/config.ts index 5e9e24d1a..eda6361f9 100644 --- a/src/server/api/routers/config.ts +++ b/src/server/api/routers/config.ts @@ -4,6 +4,9 @@ import Consola from 'consola'; import { z } from 'zod'; import { TRPCError } from '@trpc/server'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { BackendConfigType, ConfigType } from '~/types/config'; +import { getConfig } from '../../../tools/config/getConfig'; +import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; export const configRouter = createTRPCRouter({ all: publicProcedure.query(async () => { @@ -56,4 +59,104 @@ export const configRouter = createTRPCRouter({ message: 'Configuration deleted with success', }; }), + save: publicProcedure + .input( + z.object({ + name: z.string(), + config: z.custom((x) => !!x && typeof x === 'object'), + }) + ) + .mutation(async ({ input }) => { + Consola.info(`Saving updated configuration of '${input.name}' config.`); + + const previousConfig = getConfig(input.name); + + let newConfig: BackendConfigType = { + ...input.config, + apps: [ + ...input.config.apps.map((app) => ({ + ...app, + network: { + ...app.network, + statusCodes: + app.network.okStatus === undefined + ? app.network.statusCodes + : app.network.okStatus.map((x) => x.toString()), + okStatus: undefined, + }, + integration: { + ...app.integration, + properties: app.integration.properties.map((property) => { + if (property.type === 'public') { + return { + field: property.field, + type: property.type, + value: property.value, + }; + } + + const previousApp = previousConfig.apps.find( + (previousApp) => previousApp.id === app.id + ); + + const previousProperty = previousApp?.integration?.properties.find( + (previousProperty) => previousProperty.field === property.field + ); + + if (property.value !== undefined && property.value !== null) { + Consola.info( + 'Detected credential change of private secret. Value will be overwritten in configuration' + ); + return { + field: property.field, + type: property.type, + value: property.value, + }; + } + + return { + field: property.field, + type: property.type, + value: previousProperty?.value, + }; + }), + }, + })), + ], + }; + + newConfig = { + ...newConfig, + widgets: [ + ...newConfig.widgets.map((x) => { + if (x.type !== 'rss') { + return x; + } + + const rssWidget = x as IRssWidget; + + return { + ...rssWidget, + properties: { + ...rssWidget.properties, + rssFeedUrl: + typeof rssWidget.properties.rssFeedUrl === 'string' + ? [rssWidget.properties.rssFeedUrl] + : rssWidget.properties.rssFeedUrl, + }, + } as IRssWidget; + }), + ], + }; + + // Save the body in the /data/config folder with the slug as filename + const targetPath = path.join('data/configs', `${input.name}.json`); + fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); + + Consola.debug(`Config '${input.name}' has been updated and flushed to '${targetPath}'.`); + + return { + message: 'Configuration saved with success', + }; + }), }); diff --git a/src/utils/api.ts b/src/utils/api.ts index 9181abda0..d8e2b3527 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -4,13 +4,38 @@ * * We also create a few inference helpers for input and output types. */ -import { httpBatchLink, loggerLink } from '@trpc/client'; +import { createTRPCProxyClient, httpBatchLink, loggerLink } from '@trpc/client'; import { createTRPCNext } from '@trpc/next'; import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; import superjson from 'superjson'; import { type RootRouter } from '~/server/api/root'; +const getTrpcConfiguration = () => ({ + /** + * Transformer used for data de-serialization from the server. + * + * @see https://trpc.io/docs/data-transformers + */ + transformer: superjson, + + /** + * Links used to determine request flow from client to server. + * + * @see https://trpc.io/docs/links + */ + links: [ + loggerLink({ + enabled: (opts) => + process.env.NODE_ENV === 'development' || + (opts.direction === 'down' && opts.result instanceof Error), + }), + httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + }), + ], +}); + const getBaseUrl = () => { if (typeof window !== 'undefined') return ''; // browser should use relative url if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url @@ -20,30 +45,7 @@ const getBaseUrl = () => { /** A set of type-safe react-query hooks for your tRPC API. */ export const api = createTRPCNext({ config() { - return { - /** - * Transformer used for data de-serialization from the server. - * - * @see https://trpc.io/docs/data-transformers - */ - transformer: superjson, - - /** - * Links used to determine request flow from client to server. - * - * @see https://trpc.io/docs/links - */ - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === 'development' || - (opts.direction === 'down' && opts.result instanceof Error), - }), - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - }; + return getTrpcConfiguration(); }, /** * Whether tRPC should await queries when server rendering pages. @@ -66,3 +68,8 @@ export type RouterInputs = inferRouterInputs; * @example type HelloOutput = RouterOutputs['example']['hello'] */ export type RouterOutputs = inferRouterOutputs; + +/** + * A tRPC client that can be used without hooks. + */ +export const trcpProxyClient = createTRPCProxyClient(getTrpcConfiguration()); From b0b35c0f1cf2670af2cc041aee1b4fc856c80762 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 12:35:37 +0200 Subject: [PATCH 11/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20docker?= =?UTF-8?q?=20containers=20query=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/Docker/DockerModule.tsx | 18 ++++------------ src/server/api/root.ts | 2 ++ .../api/routers/docker/DockerSingleton.ts | 21 +++++++++++++++++++ src/server/api/routers/docker/router.ts | 18 ++++++++++++++++ 4 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 src/server/api/routers/docker/DockerSingleton.ts create mode 100644 src/server/api/routers/docker/router.ts diff --git a/src/modules/Docker/DockerModule.tsx b/src/modules/Docker/DockerModule.tsx index 3e8b8a582..f11bb18b2 100644 --- a/src/modules/Docker/DockerModule.tsx +++ b/src/modules/Docker/DockerModule.tsx @@ -1,14 +1,13 @@ import { ActionIcon, Drawer, Tooltip } from '@mantine/core'; import { useHotkeys } from '@mantine/hooks'; import { IconBrandDocker } from '@tabler/icons-react'; -import axios from 'axios'; import Docker from 'dockerode'; import { useTranslation } from 'next-i18next'; -import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; import { useCardStyles } from '../../components/layout/useCardStyles'; import { useConfigContext } from '../../config/provider'; +import { api } from '~/utils/api'; import ContainerActionBar from './ContainerActionBar'; import DockerTable from './DockerTable'; @@ -20,22 +19,13 @@ export default function DockerMenuButton(props: any) { const dockerEnabled = config?.settings.customization.layout.enabledDocker || false; - const { data, isLoading, refetch } = useQuery({ - queryKey: ['containers'], - queryFn: async () => { - const containers = await axios.get('/api/docker/containers'); - return containers.data; - }, + const { data, refetch } = api.docker.containers.useQuery(undefined, { enabled: dockerEnabled, }); useHotkeys([['mod+B', () => setOpened(!opened)]]); const { t } = useTranslation('modules/docker'); - useEffect(() => { - refetch(); - }, [config?.settings]); - const reload = () => { refetch(); setSelection([]); @@ -64,7 +54,7 @@ export default function DockerMenuButton(props: any) { }, }} > - + { + try { + const docker = DockerSingleton.getInstance(); + const containers = await docker.listContainers({ all: true }); + return containers; + } catch (err) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Unable to get containers', + }); + } + }), +}); From c061426846443c494f2713092b05fbebc3f77443 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 12:57:06 +0200 Subject: [PATCH 12/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20docker?= =?UTF-8?q?=20container=20actions=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/Docker/ContainerActionBar.tsx | 118 ++++++++++------------ src/server/api/routers/docker/router.ts | 54 ++++++++++ 2 files changed, 110 insertions(+), 62 deletions(-) diff --git a/src/modules/Docker/ContainerActionBar.tsx b/src/modules/Docker/ContainerActionBar.tsx index 4a8e98177..27dc4c400 100644 --- a/src/modules/Docker/ContainerActionBar.tsx +++ b/src/modules/Docker/ContainerActionBar.tsx @@ -10,56 +10,16 @@ import { IconRotateClockwise, IconTrash, } from '@tabler/icons-react'; -import axios from 'axios'; import Dockerode from 'dockerode'; import { useTranslation } from 'next-i18next'; import { useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; +import { RouterInputs, api } from '~/utils/api'; import { useConfigContext } from '../../config/provider'; import { openContextModalGeneric } from '../../tools/mantineModalManagerExtensions'; import { MatchingImages, ServiceType, tryMatchPort } from '../../tools/types'; import { AppType } from '../../types/app'; -function sendDockerCommand( - action: string, - containerId: string, - containerName: string, - reload: () => void, - t: (key: string) => string, -) { - notifications.show({ - id: containerId, - loading: true, - title: `${t(`actions.${action}.start`)} ${containerName}`, - message: undefined, - autoClose: false, - withCloseButton: false, - }); - axios - .get(`/api/docker/container/${containerId}?action=${action}`) - .then((res) => { - notifications.show({ - id: containerId, - title: containerName, - message: `${t(`actions.${action}.end`)} ${containerName}`, - icon: , - autoClose: 2000, - }); - }) - .catch((err) => { - notifications.update({ - id: containerId, - color: 'red', - title: t('errors.unknownError.title'), - message: err.response.data.reason, - autoClose: 2000, - }); - }) - .finally(() => { - reload(); - }); -} - export interface ContainerActionBarProps { selected: Dockerode.ContainerInfo[]; reload: () => void; @@ -68,8 +28,9 @@ export interface ContainerActionBarProps { export default function ContainerActionBar({ selected, reload }: ContainerActionBarProps) { const { t } = useTranslation('modules/docker'); const [isLoading, setisLoading] = useState(false); - const { name: configName, config } = useConfigContext(); + const { config } = useConfigContext(); const getLowestWrapper = () => config?.wrappers.sort((a, b) => a.position - b.position)[0]; + const sendDockerCommand = useDockerActionMutation(); if (process.env.DISABLE_EDIT_MODE === 'true') { return null; @@ -96,11 +57,7 @@ export default function ContainerActionBar({ selected, reload }: ContainerAction ) : ( - From 1632bdec39439444c570647a5df8ff56b9531539 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 17:43:10 +0200 Subject: [PATCH 29/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20usenet?= =?UTF-8?q?=20resume=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/widgets/dashDot/api.ts | 67 ++++++++------------------ src/server/api/routers/usenet/route.ts | 47 ++++++++++++++++++ src/widgets/useNet/UseNetTile.tsx | 13 +++-- 3 files changed, 77 insertions(+), 50 deletions(-) diff --git a/src/hooks/widgets/dashDot/api.ts b/src/hooks/widgets/dashDot/api.ts index 09342e32e..612380eef 100644 --- a/src/hooks/widgets/dashDot/api.ts +++ b/src/hooks/widgets/dashDot/api.ts @@ -1,9 +1,8 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; -import { Results } from 'sabnzbd-api'; import { useConfigContext } from '~/config/provider'; import { RouterInputs, api } from '~/utils/api'; -import { UsenetInfoRequestParams, UsenetInfoResponse } from '../../../pages/api/modules/usenet'; +import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet'; import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history'; import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause'; import type { @@ -11,7 +10,6 @@ import type { UsenetQueueResponse, } from '../../../pages/api/modules/usenet/queue'; import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume'; -import { queryClient } from '../../../tools/server/configurations/tanstack/queryClient.tool'; const POLLING_INTERVAL = 2000; @@ -65,7 +63,7 @@ export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) => { export const usePauseUsenetQueueMutation = (params: UsenetPauseRequestParams) => { const { name: configName } = useConfigContext(); - const { mutateAsync, mutate, ...mutation } = api.usenet.pause.useMutation(); + const { mutateAsync } = api.usenet.pause.useMutation(); const utils = api.useContext(); return async (variables: Omit) => { await mutateAsync( @@ -82,46 +80,21 @@ export const usePauseUsenetQueueMutation = (params: UsenetPauseRequestParams) => }; }; -export const useResumeUsenetQueue = (params: UsenetResumeRequestParams) => - useMutation( - ['usenetResume', ...Object.values(params)], - async () => - ( - await axios.post( - '/api/modules/usenet/resume', - {}, - { - params, - } - ) - ).data, - { - async onMutate() { - await queryClient.cancelQueries(['usenetInfo', params.appId]); - const previousInfo = queryClient.getQueryData([ - 'usenetInfo', - params.appId, - ]); - - if (previousInfo) { - queryClient.setQueryData(['usenetInfo', params.appId], { - ...previousInfo, - paused: false, - }); - } - - return { previousInfo }; +export const useResumeUsenetQueueMutation = (params: UsenetResumeRequestParams) => { + const { name: configName } = useConfigContext(); + const { mutateAsync } = api.usenet.resume.useMutation(); + const utils = api.useContext(); + return async (variables: Omit) => { + await mutateAsync( + { + configName: configName!, + ...variables, }, - onError(err, _, context) { - if (context?.previousInfo) { - queryClient.setQueryData( - ['usenetInfo', params.appId], - context.previousInfo - ); - } - }, - onSettled() { - queryClient.invalidateQueries(['usenetInfo', params.appId]); - }, - } - ); + { + onSettled() { + utils.usenet.info.invalidate({ appId: params.appId }); + }, + } + ); + }; +}; diff --git a/src/server/api/routers/usenet/route.ts b/src/server/api/routers/usenet/route.ts index b6d86127c..5e41b6a92 100644 --- a/src/server/api/routers/usenet/route.ts +++ b/src/server/api/routers/usenet/route.ts @@ -216,6 +216,53 @@ export const usenetRouter = createTRPCRouter({ return new Client(origin, apiKey).queuePause(); }), + resume: publicProcedure + .input( + z.object({ + configName: z.string(), + appId: z.string(), + }) + ) + .mutation(async ({ input }) => { + const config = getConfig(input.configName); + + const app = config.apps.find((x) => x.id === input.appId); + + if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + throw new Error(`App with ID "${input.appId}" could not be found.`); + } + + if (app.integration.type === 'nzbGet') { + const url = new URL(app.url); + const options = { + host: url.hostname, + port: url.port || (url.protocol === 'https:' ? '443' : '80'), + login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, + hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + }; + + const nzbGet = NzbgetClient(options); + + return new Promise((resolve, reject) => { + nzbGet.resumeDownload(false, (err: any, result: any) => { + if (!err) { + resolve(result); + } else { + reject(err); + } + }); + }); + } + + const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + if (!apiKey) { + throw new Error(`API Key for app "${app.name}" is missing`); + } + + const { origin } = new URL(app.url); + + return new Client(origin, apiKey).queueResume(); + }), }); export interface UsenetInfoResponse { diff --git a/src/widgets/useNet/UseNetTile.tsx b/src/widgets/useNet/UseNetTile.tsx index e1741006c..79aee3136 100644 --- a/src/widgets/useNet/UseNetTile.tsx +++ b/src/widgets/useNet/UseNetTile.tsx @@ -11,7 +11,7 @@ import { MIN_WIDTH_MOBILE } from '../../constants/constants'; import { useGetUsenetInfo, usePauseUsenetQueueMutation, - useResumeUsenetQueue, + useResumeUsenetQueueMutation, } from '../../hooks/widgets/dashDot/api'; import { humanFileSize } from '../../tools/humanFileSize'; import { AppIntegrationType } from '../../types/app'; @@ -61,7 +61,7 @@ function UseNetTile({ widget }: UseNetTileProps) { }, [downloadApps, selectedAppId]); const pauseAsync = usePauseUsenetQueueMutation({ appId: selectedAppId! }); - const { mutate: resume } = useResumeUsenetQueue({ appId: selectedAppId! }); + const resumeAsync = useResumeUsenetQueueMutation({ appId: selectedAppId! }); if (downloadApps.length === 0) { return ( @@ -107,7 +107,14 @@ function UseNetTile({ widget }: UseNetTileProps) { {!data ? null : data.paused ? ( - ) : ( From 3e24277b931250911ed516d216f2fe442ec8d55b Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 17:48:12 +0200 Subject: [PATCH 30/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20usenet?= =?UTF-8?q?=20queue=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/widgets/dashDot/api.ts | 24 ++--- src/server/api/routers/usenet/route.ts | 125 ++++++++++++++++++++++++- src/widgets/useNet/UsenetQueueList.tsx | 3 +- 3 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/hooks/widgets/dashDot/api.ts b/src/hooks/widgets/dashDot/api.ts index 612380eef..c072b3379 100644 --- a/src/hooks/widgets/dashDot/api.ts +++ b/src/hooks/widgets/dashDot/api.ts @@ -1,14 +1,9 @@ -import { useQuery } from '@tanstack/react-query'; -import axios from 'axios'; import { useConfigContext } from '~/config/provider'; import { RouterInputs, api } from '~/utils/api'; import { UsenetInfoRequestParams } from '../../../pages/api/modules/usenet'; import type { UsenetHistoryRequestParams } from '../../../pages/api/modules/usenet/history'; import { UsenetPauseRequestParams } from '../../../pages/api/modules/usenet/pause'; -import type { - UsenetQueueRequestParams, - UsenetQueueResponse, -} from '../../../pages/api/modules/usenet/queue'; +import type { UsenetQueueRequestParams } from '../../../pages/api/modules/usenet/queue'; import { UsenetResumeRequestParams } from '../../../pages/api/modules/usenet/resume'; const POLLING_INTERVAL = 2000; @@ -30,21 +25,20 @@ export const useGetUsenetInfo = ({ appId }: UsenetInfoRequestParams) => { ); }; -export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) => - useQuery( - ['usenetDownloads', ...Object.values(params)], - async () => - ( - await axios.get('/api/modules/usenet/queue', { - params, - }) - ).data, +export const useGetUsenetDownloads = (params: UsenetQueueRequestParams) => { + const { name: configName } = useConfigContext(); + return api.usenet.queue.useQuery( + { + configName: configName!, + ...params, + }, { refetchInterval: POLLING_INTERVAL, keepPreviousData: true, retry: 2, } ); +}; export const useGetUsenetHistory = (params: UsenetHistoryRequestParams) => { const { name: configName } = useConfigContext(); diff --git a/src/server/api/routers/usenet/route.ts b/src/server/api/routers/usenet/route.ts index 5e41b6a92..610ec9e73 100644 --- a/src/server/api/routers/usenet/route.ts +++ b/src/server/api/routers/usenet/route.ts @@ -1,12 +1,16 @@ +import { TRPCError } from '@trpc/server'; import dayjs from 'dayjs'; import { Client } from 'sabnzbd-api'; import { z } from 'zod'; -import { TRPCError } from '@trpc/server'; -import { NzbgetHistoryItem, NzbgetStatus } from '~/server/api/routers/usenet/nzbget/types'; +import { + NzbgetHistoryItem, + NzbgetQueueItem, + NzbgetStatus, +} from '~/server/api/routers/usenet/nzbget/types'; import { getConfig } from '~/tools/config/getConfig'; +import { UsenetHistoryItem, UsenetQueueItem } from '~/widgets/useNet/types'; import { createTRPCRouter, publicProcedure } from '../../trpc'; import { NzbgetClient } from './nzbget/nzbget-client'; -import { UsenetHistoryItem } from '~/widgets/useNet/types'; export const usenetRouter = createTRPCRouter({ info: publicProcedure @@ -263,8 +267,123 @@ export const usenetRouter = createTRPCRouter({ return new Client(origin, apiKey).queueResume(); }), + queue: publicProcedure + .input( + z.object({ + configName: z.string(), + appId: z.string(), + limit: z.number(), + offset: z.number(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const app = config.apps.find((x) => x.id === input.appId); + + if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + throw new Error(`App with ID "${input.appId}" could not be found.`); + } + + if (app.integration.type === 'nzbGet') { + const url = new URL(app.url); + const options = { + host: url.hostname, + port: url.port || (url.protocol === 'https:' ? '443' : '80'), + login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, + hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + }; + + const nzbGet = NzbgetClient(options); + + const nzbgetQueue: NzbgetQueueItem[] = await new Promise((resolve, reject) => { + nzbGet.listGroups((err: any, result: NzbgetQueueItem[]) => { + if (!err) { + resolve(result); + } else { + reject(err); + } + }); + }); + + if (!nzbgetQueue) { + throw new Error('Error while getting NZBGet queue'); + } + + const nzbgetStatus: NzbgetStatus = await new Promise((resolve, reject) => { + nzbGet.status((err: any, result: NzbgetStatus) => { + if (!err) { + resolve(result); + } else { + reject(err); + } + }); + }); + + if (!nzbgetStatus) { + throw new Error('Error while getting NZBGet status'); + } + + const nzbgetItems: UsenetQueueItem[] = nzbgetQueue.map((item: NzbgetQueueItem) => ({ + id: item.NZBID.toString(), + name: item.NZBName, + progress: (item.DownloadedSizeMB / item.FileSizeMB) * 100, + eta: (item.RemainingSizeMB * 1000000) / nzbgetStatus.DownloadRate, + // Multiple MB to get bytes + size: item.FileSizeMB * 1000 * 1000, + state: getNzbgetState(item.Status), + })); + + return { + items: nzbgetItems, + total: nzbgetItems.length, + }; + } + + const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + if (!apiKey) { + throw new Error(`API Key for app "${app.name}" is missing`); + } + + const { origin } = new URL(app.url); + const queue = await new Client(origin, apiKey).queue(input.offset, input.limit); + + const items: UsenetQueueItem[] = queue.slots.map((slot) => { + const [hours, minutes, seconds] = slot.timeleft.split(':'); + const eta = dayjs.duration({ + hour: parseInt(hours, 10), + minutes: parseInt(minutes, 10), + seconds: parseInt(seconds, 10), + } as any); + + return { + id: slot.nzo_id, + eta: eta.asSeconds(), + name: slot.filename, + progress: parseFloat(slot.percentage), + size: parseFloat(slot.mb) * 1000 * 1000, + state: slot.status.toLowerCase() as any, + }; + }); + + return { + items, + total: queue.noofslots, + }; + }), }); +function getNzbgetState(status: string) { + switch (status) { + case 'QUEUED': + return 'queued'; + case 'PAUSED ': + return 'paused'; + default: + return 'downloading'; + } +} + export interface UsenetInfoResponse { paused: boolean; sizeLeft: number; diff --git a/src/widgets/useNet/UsenetQueueList.tsx b/src/widgets/useNet/UsenetQueueList.tsx index b3ed643dd..e5a613e86 100644 --- a/src/widgets/useNet/UsenetQueueList.tsx +++ b/src/widgets/useNet/UsenetQueueList.tsx @@ -16,7 +16,6 @@ import { } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; import { IconAlertCircle, IconPlayerPause, IconPlayerPlay } from '@tabler/icons-react'; -import { AxiosError } from 'axios'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { useTranslation } from 'next-i18next'; @@ -70,7 +69,7 @@ export const UsenetQueueList: FunctionComponent = ({ appId > {t('queue.error.message')} - {(error as AxiosError)?.response?.data as string} + {error.data} From 696da5dbec37476cc0d6b03ab7cd1ac907e0c6ec Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 17:48:55 +0200 Subject: [PATCH 31/36] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Move=20nzbget=20sdk?= =?UTF-8?q?=20to=20routers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/api/modules/downloads/index.ts | 4 +- src/pages/api/modules/usenet/history.ts | 4 +- src/pages/api/modules/usenet/index.ts | 4 +- .../api/modules/usenet/nzbget/nzbget-api.d.ts | 1 - .../modules/usenet/nzbget/nzbget-client.ts | 22 --- src/pages/api/modules/usenet/nzbget/types.ts | 149 ------------------ src/pages/api/modules/usenet/pause.ts | 2 +- src/pages/api/modules/usenet/queue.ts | 4 +- src/pages/api/modules/usenet/resume.ts | 2 +- 9 files changed, 10 insertions(+), 182 deletions(-) delete mode 100644 src/pages/api/modules/usenet/nzbget/nzbget-api.d.ts delete mode 100644 src/pages/api/modules/usenet/nzbget/nzbget-client.ts delete mode 100644 src/pages/api/modules/usenet/nzbget/types.ts diff --git a/src/pages/api/modules/downloads/index.ts b/src/pages/api/modules/downloads/index.ts index 6d80db458..d49290039 100644 --- a/src/pages/api/modules/downloads/index.ts +++ b/src/pages/api/modules/downloads/index.ts @@ -13,8 +13,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; -import { NzbgetClient } from '../usenet/nzbget/nzbget-client'; -import { NzbgetQueueItem, NzbgetStatus } from '../usenet/nzbget/types'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; +import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types'; import { ConfigAppType, IntegrationField } from '../../../../types/app'; import { getConfig } from '../../../../tools/config/getConfig'; import { UsenetQueueItem } from '../../../../widgets/useNet/types'; diff --git a/src/pages/api/modules/usenet/history.ts b/src/pages/api/modules/usenet/history.ts index 88976e383..f75e3bdb6 100644 --- a/src/pages/api/modules/usenet/history.ts +++ b/src/pages/api/modules/usenet/history.ts @@ -3,8 +3,8 @@ import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; -import { NzbgetHistoryItem } from './nzbget/types'; -import { NzbgetClient } from './nzbget/nzbget-client'; +import { NzbgetHistoryItem } from '../../../../server/api/routers/usenet/nzbget/types'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; import { getConfig } from '../../../../tools/config/getConfig'; import { UsenetHistoryItem } from '../../../../widgets/useNet/types'; diff --git a/src/pages/api/modules/usenet/index.ts b/src/pages/api/modules/usenet/index.ts index dc041de1d..bf539c38f 100644 --- a/src/pages/api/modules/usenet/index.ts +++ b/src/pages/api/modules/usenet/index.ts @@ -4,8 +4,8 @@ import duration from 'dayjs/plugin/duration'; import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; -import { NzbgetClient } from './nzbget/nzbget-client'; -import { NzbgetStatus } from './nzbget/types'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; +import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types'; dayjs.extend(duration); diff --git a/src/pages/api/modules/usenet/nzbget/nzbget-api.d.ts b/src/pages/api/modules/usenet/nzbget/nzbget-api.d.ts deleted file mode 100644 index 9c77b63dc..000000000 --- a/src/pages/api/modules/usenet/nzbget/nzbget-api.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module 'nzbget-api'; diff --git a/src/pages/api/modules/usenet/nzbget/nzbget-client.ts b/src/pages/api/modules/usenet/nzbget/nzbget-client.ts deleted file mode 100644 index ef614dd57..000000000 --- a/src/pages/api/modules/usenet/nzbget/nzbget-client.ts +++ /dev/null @@ -1,22 +0,0 @@ -import NZBGet from 'nzbget-api'; -import { NzbgetClientOptions } from './types'; - -export function NzbgetClient(options: NzbgetClientOptions) { - if (!options?.host) { - throw new Error('Cannot connect to NZBGet. Missing host in app config.'); - } - - if (!options?.port) { - throw new Error('Cannot connect to NZBGet. Missing port in app config.'); - } - - if (!options?.login) { - throw new Error('Cannot connect to NZBGet. Missing username in app config.'); - } - - if (!options?.hash) { - throw new Error('Cannot connect to NZBGet. Missing password in app config.'); - } - - return new NZBGet(options); -} diff --git a/src/pages/api/modules/usenet/nzbget/types.ts b/src/pages/api/modules/usenet/nzbget/types.ts deleted file mode 100644 index e51f5861a..000000000 --- a/src/pages/api/modules/usenet/nzbget/types.ts +++ /dev/null @@ -1,149 +0,0 @@ -export interface NzbgetHistoryItem { - NZBID: number; - Kind: 'NZB' | 'URL' | 'DUP'; - NZBFilename: string; - Name: string; - URL: string; - HistoryTime: number; - DestDir: string; - FinalDir: string; - Category: string; - FileSizeLo: number; - FileSizeHi: number; - FileSizeMB: number; - FileCount: number; - RemainingFileCount: number; - MinPostTime: number; - MaxPostTime: number; - TotalArticles: number; - SuccessArticles: number; - FailedArticles: number; - Health: number; - DownloadedSizeLo: number; - DownloadedSizeHi: number; - DownloadedSizeMB: number; - DownloadTimeSec: number; - PostTotalTimeSec: number; - ParTimeSec: number; - RepairTimeSec: number; - UnpackTimeSec: number; - MessageCount: number; - DupeKey: string; - DupeScore: number; - DupeMode: 'SCORE' | 'ALL' | 'FORCE'; - Status: string; - ParStatus: 'NONE' | 'FAILURE' | 'REPAIR_POSSIBLE' | 'SUCCESS' | 'MANUAL'; - ExParStatus: 'RECIPIENT' | 'DONOR'; - UnpackStatus: 'NONE' | 'FAILURE' | 'SPACE' | 'PASSWORD' | 'SUCCESS'; - UrlStatus: 'NONE' | 'SUCCESS' | 'FAILURE' | 'SCAN_SKIPPED' | 'SCAN_FAILURE'; - ScriptStatus: 'NONE' | 'FAILURE' | 'SUCCESS'; - ScriptStatuses: []; - MoveStatus: 'NONE' | 'SUCCESS' | 'FAILURE'; - DeleteStatus: 'NONE' | 'MANUAL' | 'HEALTH' | 'DUPE' | 'BAD' | 'SCAN' | 'COPY'; - MarkStatus: 'NONE' | 'GOOD' | 'BAD'; - ExtraParBlocks: number; - Parameters: []; - ServerStats: []; -} - -export interface NzbgetQueueItem { - NZBID: number; - NZBFilename: string; - NZBName: string; - Kind: 'NZB' | 'URL'; - URL: string; - DestDir: string; - FinalDir: string; - Category: string; - FileSizeLo: number; - FileSizeHi: number; - FileSizeMB: number; - RemainingSizeLo: number; - RemainingSizeHi: number; - RemainingSizeMB: number; - PausedSizeLo: number; - PausedSizeHi: number; - PausedSizeMB: number; - FileCount: number; - RemainingFileCount: number; - RemainingParCount: number; - MinPostTime: number; - MaxPostTime: number; - MaxPriority: number; - ActiveDownloads: number; - Status: - | 'QUEUED' - | 'PAUSED' - | 'DOWNLOADING' - | 'FETCHING' - | 'PP_QUEUED' - | 'LOADING_PARS' - | 'VERIFYING_SOURCES' - | 'REPAIRING' - | 'VERIFYING_REPAIRED' - | 'RENAMING' - | 'UNPACKING' - | 'MOVING' - | 'EXECUTING_SCRIPT' - | 'PP_FINISHED'; - TotalArticles: number; - SuccessArticles: number; - FailedArticles: number; - Health: number; - CriticalHealth: number; - DownloadedSizeLo: number; - DownloadedSizeHi: number; - DownloadedSizeMB: number; - DownloadTimeSec: number; - MessageCount: number; - DupeKey: string; - DupeScore: number; - DupeMode: string; - Parameters: []; - ServerStats: []; - PostInfoText: string; - PostStageProgress: number; - PostTotalTimeSec: number; - PostStageTimeSec: number; -} - -export interface NzbgetStatus { - RemainingSizeLo: number; - RemainingSizeHi: number; - RemainingSizeMB: number; - ForcedSizeLo: number; - ForcedSizeHi: number; - ForcedSizeMB: number; - DownloadedSizeLo: number; - DownloadedSizeHi: number; - DownloadedSizeMB: number; - ArticleCacheLo: number; - ArticleCacheHi: number; - ArticleCacheMB: number; - DownloadRate: number; - AverageDownloadRate: number; - DownloadLimit: number; - ThreadCount: number; - PostJobCount: number; - UrlCount: number; - UpTimeSec: number; - DownloadTimeSec: number; - ServerStandBy: boolean; - DownloadPaused: boolean; - PostPaused: boolean; - ScanPaused: boolean; - ServerTime: number; - ResumeTime: number; - FeedActive: boolean; - FreeDiskSpaceLo: number; - FreeDiskSpaceHi: number; - FreeDiskSpaceMB: number; - NewsServers: []; -} - -export interface NzbgetClientOptions { - host: string; - port: string; - login: string | undefined; - hash: string | undefined; -} diff --git a/src/pages/api/modules/usenet/pause.ts b/src/pages/api/modules/usenet/pause.ts index f3fa5fd8d..4faef1ba3 100644 --- a/src/pages/api/modules/usenet/pause.ts +++ b/src/pages/api/modules/usenet/pause.ts @@ -4,7 +4,7 @@ import duration from 'dayjs/plugin/duration'; import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; -import { NzbgetClient } from './nzbget/nzbget-client'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; dayjs.extend(duration); diff --git a/src/pages/api/modules/usenet/queue.ts b/src/pages/api/modules/usenet/queue.ts index 5aeb5caf9..1dc3d84e8 100644 --- a/src/pages/api/modules/usenet/queue.ts +++ b/src/pages/api/modules/usenet/queue.ts @@ -5,8 +5,8 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; import { UsenetQueueItem } from '../../../../widgets/useNet/types'; -import { NzbgetClient } from './nzbget/nzbget-client'; -import { NzbgetQueueItem, NzbgetStatus } from './nzbget/types'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; +import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types'; dayjs.extend(duration); diff --git a/src/pages/api/modules/usenet/resume.ts b/src/pages/api/modules/usenet/resume.ts index 1e1c18ec4..17afa9fe7 100644 --- a/src/pages/api/modules/usenet/resume.ts +++ b/src/pages/api/modules/usenet/resume.ts @@ -4,7 +4,7 @@ import duration from 'dayjs/plugin/duration'; import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; -import { NzbgetClient } from './nzbget/nzbget-client'; +import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; dayjs.extend(duration); From bef6c56aebdd4a9f1c578e78f079904fe15b75b3 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 18:00:24 +0200 Subject: [PATCH 32/36] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Migrate=20calenda?= =?UTF-8?q?r=20to=20tRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/api/root.ts | 2 + src/server/api/routers/calendar.ts | 95 +++++++++++++++++++++++++++ src/widgets/calendar/CalendarTile.tsx | 32 ++++----- 3 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 src/server/api/routers/calendar.ts diff --git a/src/server/api/root.ts b/src/server/api/root.ts index f76df3cbe..e3731c3aa 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -11,6 +11,7 @@ import { mediaRequestsRouter } from './routers/media-request'; import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; import { usenetRouter } from './routers/usenet/route'; +import { calendarRouter } from './routers/calendar'; /** * This is the primary router for your server. @@ -30,6 +31,7 @@ export const rootRouter = createTRPCRouter({ mediaServer: mediaServerRouter, overseerr: overseerrRouter, usenet: usenetRouter, + calendar: calendarRouter, }); // export type definition of API diff --git a/src/server/api/routers/calendar.ts b/src/server/api/routers/calendar.ts new file mode 100644 index 000000000..42761c073 --- /dev/null +++ b/src/server/api/routers/calendar.ts @@ -0,0 +1,95 @@ +import axios from 'axios'; +import Consola from 'consola'; +import { z } from 'zod'; +import { getConfig } from '~/tools/config/getConfig'; +import { AppIntegrationType } from '~/types/app'; +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const calendarRouter = createTRPCRouter({ + medias: publicProcedure + .input( + z.object({ + configName: z.string(), + month: z.number().min(1).max(12), + year: z.number().min(1900).max(2300), + options: z.object({ + useSonarrv4: z.boolean().optional().default(false), + }), + }) + ) + .query(async ({ input }) => { + const { configName, month, year, options } = input; + const config = getConfig(configName); + + const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [ + 'sonarr', + 'radarr', + 'readarr', + 'lidarr', + ]; + const mediaApps = config.apps.filter( + (app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type) + ); + + const integrationTypeEndpointMap = new Map([ + ['sonarr', input.options.useSonarrv4 ? '/api/v3/calendar' : '/api/calendar'], + ['radarr', '/api/v3/calendar'], + ['lidarr', '/api/v1/calendar'], + ['readarr', '/api/v1/calendar'], + ]); + + const promises = mediaApps.map(async (app) => { + const integration = app.integration!; + const endpoint = integrationTypeEndpointMap.get(integration.type); + if (!endpoint) { + return { + type: integration.type, + items: [], + success: false, + }; + } + + // Get the origin URL + let { href: origin } = new URL(app.url); + if (origin.endsWith('/')) { + origin = origin.slice(0, -1); + } + + const start = new Date(year, month - 1, 1); // First day of month + const end = new Date(year, month, 0); // Last day of month + + const apiKey = integration.properties.find((x) => x.field === 'apiKey')?.value; + if (!apiKey) return { type: integration.type, items: [], success: false }; + return axios + .get( + `${origin}${endpoint}?apiKey=${apiKey}&end=${end.toISOString()}&start=${start.toISOString()}&includeSeries=true&includeEpisodeFile=true&includeEpisodeImages=true` + ) + .then((x) => ({ type: integration.type, items: x.data as any[], success: true })) + .catch((err) => { + Consola.error( + `failed to process request to app '${integration.type}' (${app.id}): ${err}` + ); + return { + type: integration.type, + items: [], + success: false, + }; + }); + }); + + const medias = await Promise.all(promises); + + const countFailed = medias.filter((x) => !x.success).length; + if (countFailed > 0) { + Consola.warn(`A total of ${countFailed} apps for the calendar widget failed`); + } + + return { + tvShows: medias.filter((m) => m.type === 'sonarr').flatMap((m) => m.items), + movies: medias.filter((m) => m.type === 'radarr').flatMap((m) => m.items), + books: medias.filter((m) => m.type === 'readarr').flatMap((m) => m.items), + musics: medias.filter((m) => m.type === 'lidarr').flatMap((m) => m.items), + totalCount: medias.reduce((p, c) => p + c.items.length, 0), + }; + }), +}); diff --git a/src/widgets/calendar/CalendarTile.tsx b/src/widgets/calendar/CalendarTile.tsx index c776e19d9..de320e212 100644 --- a/src/widgets/calendar/CalendarTile.tsx +++ b/src/widgets/calendar/CalendarTile.tsx @@ -1,16 +1,16 @@ import { useMantineTheme } from '@mantine/core'; import { Calendar } from '@mantine/dates'; import { IconCalendarTime } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; import { i18n } from 'next-i18next'; import { useState } from 'react'; +import { api } from '~/utils/api'; +import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore'; import { useConfigContext } from '../../config/provider'; import { defineWidget } from '../helper'; import { IWidget } from '../widgets'; import { CalendarDay } from './CalendarDay'; import { getBgColorByDateAndTheme } from './bg-calculator'; import { MediasType } from './type'; -import { useEditModeStore } from '../../components/Dashboard/Views/useEditModeStore'; const definition = defineWidget({ id: 'calendar', @@ -55,22 +55,18 @@ function CalendarTile({ widget }: CalendarTileProps) { const [month, setMonth] = useState(new Date()); const isEditMode = useEditModeStore((x) => x.enabled); - const { data: medias } = useQuery({ - queryKey: [ - 'calendar/medias', - { month: month.getMonth(), year: month.getFullYear(), v4: widget.properties.useSonarrv4 }, - ], - staleTime: 1000 * 60 * 60 * 5, - enabled: isEditMode === false, - queryFn: async () => - (await ( - await fetch( - `/api/modules/calendar?year=${month.getFullYear()}&month=${ - month.getMonth() + 1 - }&configName=${configName}&widgetId=${widget.id}` - ) - ).json()) as MediasType, - }); + const { data: medias } = api.calendar.medias.useQuery( + { + configName: configName!, + month: month.getMonth() + 1, + year: month.getFullYear(), + options: { useSonarrv4: widget.properties.useSonarrv4 }, + }, + { + staleTime: 1000 * 60 * 60 * 5, + enabled: isEditMode === false, + } + ); return ( Date: Sat, 10 Jun 2023 18:19:06 +0200 Subject: [PATCH 33/36] =?UTF-8?q?=F0=9F=90=9B=20Fix=20pull=20request=20iss?= =?UTF-8?q?ues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/store.ts | 3 +- .../mutations/useCopyConfigMutation.tsx | 57 ------------------- src/widgets/useNet/UsenetHistoryList.tsx | 5 +- 3 files changed, 3 insertions(+), 62 deletions(-) delete mode 100644 src/tools/config/mutations/useCopyConfigMutation.tsx diff --git a/src/config/store.ts b/src/config/store.ts index d4558ad22..6c0a45279 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -1,7 +1,6 @@ -import axios from 'axios'; import { create } from 'zustand'; +import { trcpProxyClient } from '~/utils/api'; import { ConfigType } from '../types/config'; -import { api, trcpProxyClient } from '~/utils/api'; export const useConfigStore = create((set, get) => ({ configs: [], diff --git a/src/tools/config/mutations/useCopyConfigMutation.tsx b/src/tools/config/mutations/useCopyConfigMutation.tsx deleted file mode 100644 index a5f2de91d..000000000 --- a/src/tools/config/mutations/useCopyConfigMutation.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { showNotification } from '@mantine/notifications'; -import { IconCheck, IconX } from '@tabler/icons-react'; -import { useMutation } from '@tanstack/react-query'; -import { useTranslation } from 'next-i18next'; -import { useConfigContext } from '../../../config/provider'; -import { ConfigType } from '../../../types/config'; -import { queryClient } from '../../server/configurations/tanstack/queryClient.tool'; - -export const useCopyConfigMutation = (configName: string) => { - const { config } = useConfigContext(); - const { t } = useTranslation(['settings/general/config-changer']); - - return useMutation({ - mutationKey: ['configs/copy', { configName }], - mutationFn: () => fetchCopy(configName, config), - onSuccess() { - showNotification({ - title: t('modal.copy.events.configCopied.title'), - icon: , - color: 'green', - autoClose: 1500, - radius: 'md', - message: t('modal.copy.events.configCopied.message', { configName }), - }); - // Invalidate a query to fetch new config - queryClient.invalidateQueries(['config/get-all']); - }, - onError() { - showNotification({ - title: t('modal.events.configNotCopied.title'), - icon: , - color: 'red', - autoClose: 1500, - radius: 'md', - message: t('modal.events.configNotCopied.message', { configName }), - }); - }, - }); -}; - -const fetchCopy = async (configName: string, config: ConfigType | undefined) => { - if (!config) { - throw new Error('config is not defiend'); - } - - const copiedConfig = config; - copiedConfig.configProperties.name = configName; - - const response = await fetch(`/api/configs/${configName}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(config), - }); - return response.json(); -}; diff --git a/src/widgets/useNet/UsenetHistoryList.tsx b/src/widgets/useNet/UsenetHistoryList.tsx index a012e4689..1c129fb93 100644 --- a/src/widgets/useNet/UsenetHistoryList.tsx +++ b/src/widgets/useNet/UsenetHistoryList.tsx @@ -13,14 +13,13 @@ import { } from '@mantine/core'; import { useElementSize } from '@mantine/hooks'; import { IconAlertCircle } from '@tabler/icons-react'; -import { AxiosError } from 'axios'; import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import { useTranslation } from 'next-i18next'; import { FunctionComponent, useState } from 'react'; import { useGetUsenetHistory } from '../../hooks/widgets/dashDot/api'; -import { humanFileSize } from '../../tools/humanFileSize'; import { parseDuration } from '../../tools/client/parseDuration'; +import { humanFileSize } from '../../tools/humanFileSize'; dayjs.extend(duration); @@ -65,7 +64,7 @@ export const UsenetHistoryList: FunctionComponent = ({ a > {t('modules/usenet:history.error.message')} - {(error as AxiosError)?.response?.data as string} + {error.message} From c1658d68e1a7b687a1bf9ddc5616248167e0ca73 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 19:04:54 +0200 Subject: [PATCH 34/36] =?UTF-8?q?=F0=9F=90=9B=20Fix=20pull=20request=20iss?= =?UTF-8?q?ues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/api/modules/calendar.ts | 11 ++--- src/pages/api/modules/downloads/index.ts | 5 ++- src/pages/api/modules/media-requests/index.ts | 12 ++--- src/pages/api/modules/media-server/index.ts | 19 ++++---- src/pages/api/modules/usenet/history.ts | 7 +-- src/pages/api/modules/usenet/index.ts | 7 +-- src/pages/api/modules/usenet/pause.ts | 7 +-- src/pages/api/modules/usenet/queue.ts | 7 +-- src/pages/api/modules/usenet/resume.ts | 7 +-- src/server/api/root.ts | 2 +- src/server/api/routers/calendar.ts | 11 ++--- src/server/api/routers/download.ts | 5 ++- src/server/api/routers/media-request.ts | 3 +- src/server/api/routers/media-server.ts | 19 ++++---- .../routers/usenet/{route.ts => router.ts} | 45 ++++++++++--------- src/tools/client/app-properties.ts | 25 ++++++++++- 16 files changed, 115 insertions(+), 77 deletions(-) rename src/server/api/routers/usenet/{route.ts => router.ts} (83%) diff --git a/src/pages/api/modules/calendar.ts b/src/pages/api/modules/calendar.ts index e892ae6f9..3f0169ec3 100644 --- a/src/pages/api/modules/calendar.ts +++ b/src/pages/api/modules/calendar.ts @@ -5,8 +5,9 @@ import Consola from 'consola'; import { NextApiRequest, NextApiResponse } from 'next'; import { z } from 'zod'; -import { AppIntegrationType } from '../../../types/app'; +import { AppIntegrationType, IntegrationType } from '../../../types/app'; import { getConfig } from '../../../tools/config/getConfig'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; export default async (req: NextApiRequest, res: NextApiResponse) => { // Filter out if the reuqest is a POST or a GET @@ -51,14 +52,14 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { const calendar = config.widgets.find((w) => w.type === 'calendar' && w.id === widgetId); const useSonarrv4 = calendar?.properties.useSonarrv4 ?? false; - const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [ + const mediaAppIntegrationTypes = [ 'sonarr', 'radarr', 'readarr', 'lidarr', - ]; - const mediaApps = config.apps.filter( - (app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type) + ] as const satisfies readonly IntegrationType[]; + const mediaApps = config.apps.filter((app) => + checkIntegrationsType(app.integration, mediaAppIntegrationTypes) ); const IntegrationTypeEndpointMap = new Map([ diff --git a/src/pages/api/modules/downloads/index.ts b/src/pages/api/modules/downloads/index.ts index d49290039..442da77d5 100644 --- a/src/pages/api/modules/downloads/index.ts +++ b/src/pages/api/modules/downloads/index.ts @@ -22,6 +22,7 @@ import { NormalizedDownloadAppStat, NormalizedDownloadQueueResponse, } from '../../../../types/api/downloads/queue/NormalizedDownloadQueueResponse'; +import { findAppProperty } from '~/tools/client/app-properties'; const Get = async (request: NextApiRequest, response: NextApiResponse) => { const configName = getCookie('config-name', { req: request }); @@ -151,8 +152,8 @@ const GetDataFromClient = async ( const options = { host: url.hostname, port: url.port, - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); diff --git a/src/pages/api/modules/media-requests/index.ts b/src/pages/api/modules/media-requests/index.ts index 48d13a738..9e9c0e866 100644 --- a/src/pages/api/modules/media-requests/index.ts +++ b/src/pages/api/modules/media-requests/index.ts @@ -5,13 +5,14 @@ import { getConfig } from '../../../../tools/config/getConfig'; import { MediaRequest } from '../../../../widgets/media-requests/media-request-types'; import { MediaRequestListWidget } from '../../../../widgets/media-requests/MediaRequestListTile'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; const Get = async (request: NextApiRequest, response: NextApiResponse) => { const configName = getCookie('config-name', { req: request }); const config = getConfig(configName?.toString() ?? 'default'); const apps = config.apps.filter((app) => - ['overseerr', 'jellyseerr'].includes(app.integration?.type ?? '') + checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr']) ); Consola.log(`Retrieving media requests from ${apps.length} apps`); @@ -24,11 +25,12 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { }) .then(async (response) => { const body = (await response.json()) as OverseerrResponse; - const mediaWidget = config.widgets.find( - (x) => x.type === 'media-requests-list') as MediaRequestListWidget | undefined; + const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as + | MediaRequestListWidget + | undefined; if (!mediaWidget) { - Consola.log('No media-requests-list found'); - return Promise.resolve([]); + Consola.log('No media-requests-list found'); + return Promise.resolve([]); } const appUrl = mediaWidget.properties.replaceLinksWithExternalHost ? app.behaviour.externalUrl diff --git a/src/pages/api/modules/media-server/index.ts b/src/pages/api/modules/media-server/index.ts index 9085c02bc..f1bee1f55 100644 --- a/src/pages/api/modules/media-server/index.ts +++ b/src/pages/api/modules/media-server/index.ts @@ -18,6 +18,7 @@ import { GenericSessionInfo, } from '../../../../types/api/media-server/session-info'; import { PlexClient } from '../../../../tools/server/sdk/plex/plexClient'; +import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties'; const jellyfin = new Jellyfin({ clientInfo: { @@ -35,7 +36,7 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { const config = getConfig(configName?.toString() ?? 'default'); const apps = config.apps.filter((app) => - ['jellyfin', 'plex'].includes(app.integration?.type ?? '') + checkIntegrationsType(app.integration, ['jellyfin', 'plex']) ); const servers = await Promise.all( @@ -66,9 +67,9 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { const handleServer = async (app: ConfigAppType): Promise => { switch (app.integration?.type) { case 'jellyfin': { - const username = app.integration.properties.find((x) => x.field === 'username'); + const username = findAppProperty(app, 'username'); - if (!username || !username.value) { + if (!username) { return { appId: app.id, serverAddress: app.url, @@ -79,9 +80,9 @@ const handleServer = async (app: ConfigAppType): Promise x.field === 'password'); + const password = findAppProperty(app, 'password'); - if (!password || !password.value) { + if (!password) { return { appId: app.id, serverAddress: app.url, @@ -94,7 +95,7 @@ const handleServer = async (app: ConfigAppType): Promise x.field === 'apiKey'); + const apiKey = findAppProperty(app, 'apiKey'); - if (!apiKey || !apiKey.value) { + if (!apiKey) { return { serverAddress: app.url, sessions: [], @@ -179,7 +180,7 @@ const handleServer = async (app: ConfigAppType): Promise x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -77,7 +78,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { case 'sabnzbd': { const { origin } = new URL(app.url); - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/pages/api/modules/usenet/index.ts b/src/pages/api/modules/usenet/index.ts index bf539c38f..13f84b0f8 100644 --- a/src/pages/api/modules/usenet/index.ts +++ b/src/pages/api/modules/usenet/index.ts @@ -6,6 +6,7 @@ import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; import { NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types'; +import { findAppProperty } from '~/tools/client/app-properties'; dayjs.extend(duration); @@ -39,8 +40,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -70,7 +71,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { break; } case 'sabnzbd': { - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/pages/api/modules/usenet/pause.ts b/src/pages/api/modules/usenet/pause.ts index 4faef1ba3..c6d4fe1f3 100644 --- a/src/pages/api/modules/usenet/pause.ts +++ b/src/pages/api/modules/usenet/pause.ts @@ -5,6 +5,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; +import { findAppProperty } from '~/tools/client/app-properties'; dayjs.extend(duration); @@ -31,8 +32,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) { const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -49,7 +50,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) { break; } case 'sabnzbd': { - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/pages/api/modules/usenet/queue.ts b/src/pages/api/modules/usenet/queue.ts index 1dc3d84e8..c52c59fc6 100644 --- a/src/pages/api/modules/usenet/queue.ts +++ b/src/pages/api/modules/usenet/queue.ts @@ -7,6 +7,7 @@ import { getConfig } from '../../../../tools/config/getConfig'; import { UsenetQueueItem } from '../../../../widgets/useNet/types'; import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; import { NzbgetQueueItem, NzbgetStatus } from '../../../../server/api/routers/usenet/nzbget/types'; +import { findAppProperty } from '~/tools/client/app-properties'; dayjs.extend(duration); @@ -40,8 +41,8 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -91,7 +92,7 @@ async function Get(req: NextApiRequest, res: NextApiResponse) { break; } case 'sabnzbd': { - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/pages/api/modules/usenet/resume.ts b/src/pages/api/modules/usenet/resume.ts index 17afa9fe7..7df66b824 100644 --- a/src/pages/api/modules/usenet/resume.ts +++ b/src/pages/api/modules/usenet/resume.ts @@ -5,6 +5,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { Client } from 'sabnzbd-api'; import { getConfig } from '../../../../tools/config/getConfig'; import { NzbgetClient } from '../../../../server/api/routers/usenet/nzbget/nzbget-client'; +import { findAppProperty } from '~/tools/client/app-properties'; dayjs.extend(duration); @@ -32,8 +33,8 @@ async function Post(req: NextApiRequest, res: NextApiResponse) { const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -50,7 +51,7 @@ async function Post(req: NextApiRequest, res: NextApiResponse) { break; } case 'sabnzbd': { - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/server/api/root.ts b/src/server/api/root.ts index e3731c3aa..5395df87d 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -10,7 +10,7 @@ import { downloadRouter } from './routers/download'; import { mediaRequestsRouter } from './routers/media-request'; import { mediaServerRouter } from './routers/media-server'; import { overseerrRouter } from './routers/overseerr'; -import { usenetRouter } from './routers/usenet/route'; +import { usenetRouter } from './routers/usenet/router'; import { calendarRouter } from './routers/calendar'; /** diff --git a/src/server/api/routers/calendar.ts b/src/server/api/routers/calendar.ts index 42761c073..2b83a6d79 100644 --- a/src/server/api/routers/calendar.ts +++ b/src/server/api/routers/calendar.ts @@ -2,8 +2,9 @@ import axios from 'axios'; import Consola from 'consola'; import { z } from 'zod'; import { getConfig } from '~/tools/config/getConfig'; -import { AppIntegrationType } from '~/types/app'; +import { AppIntegrationType, IntegrationType } from '~/types/app'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; export const calendarRouter = createTRPCRouter({ medias: publicProcedure @@ -21,14 +22,14 @@ export const calendarRouter = createTRPCRouter({ const { configName, month, year, options } = input; const config = getConfig(configName); - const mediaAppIntegrationTypes: AppIntegrationType['type'][] = [ + const mediaAppIntegrationTypes = [ 'sonarr', 'radarr', 'readarr', 'lidarr', - ]; - const mediaApps = config.apps.filter( - (app) => app.integration && mediaAppIntegrationTypes.includes(app.integration.type) + ] as const satisfies readonly IntegrationType[]; + const mediaApps = config.apps.filter((app) => + checkIntegrationsType(app.integration, mediaAppIntegrationTypes) ); const integrationTypeEndpointMap = new Map([ diff --git a/src/server/api/routers/download.ts b/src/server/api/routers/download.ts index c1171f50c..654a58f88 100644 --- a/src/server/api/routers/download.ts +++ b/src/server/api/routers/download.ts @@ -16,6 +16,7 @@ import { import { ConfigAppType, IntegrationField } from '~/types/app'; import { UsenetQueueItem } from '~/widgets/useNet/types'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { findAppProperty } from '~/tools/client/app-properties'; export const downloadRouter = createTRPCRouter({ get: publicProcedure @@ -155,8 +156,8 @@ const GetDataFromClient = async ( const options = { host: url.hostname, port: url.port, - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); diff --git a/src/server/api/routers/media-request.ts b/src/server/api/routers/media-request.ts index daf69aa20..c68227730 100644 --- a/src/server/api/routers/media-request.ts +++ b/src/server/api/routers/media-request.ts @@ -4,6 +4,7 @@ import { getConfig } from '~/tools/config/getConfig'; import { MediaRequest } from '~/widgets/media-requests/media-request-types'; import { createTRPCRouter, publicProcedure } from '../trpc'; import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; export const mediaRequestsRouter = createTRPCRouter({ all: publicProcedure @@ -16,7 +17,7 @@ export const mediaRequestsRouter = createTRPCRouter({ const config = getConfig(input.configName); const apps = config.apps.filter((app) => - ['overseerr', 'jellyseerr'].includes(app.integration?.type ?? '') + checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr']) ); Consola.log(`Retrieving media requests from ${apps.length} apps`); diff --git a/src/server/api/routers/media-server.ts b/src/server/api/routers/media-server.ts index f645fe62b..7a70a76d7 100644 --- a/src/server/api/routers/media-server.ts +++ b/src/server/api/routers/media-server.ts @@ -11,6 +11,7 @@ import { MediaServersResponseType } from '~/types/api/media-server/response'; import { GenericCurrentlyPlaying, GenericSessionInfo } from '~/types/api/media-server/session-info'; import { ConfigAppType } from '~/types/app'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties'; const jellyfin = new Jellyfin({ clientInfo: { @@ -34,7 +35,7 @@ export const mediaServerRouter = createTRPCRouter({ const config = getConfig(input.configName); const apps = config.apps.filter((app) => - ['jellyfin', 'plex'].includes(app.integration?.type ?? '') + checkIntegrationsType(app.integration, ['jellyfin', 'plex']) ); const servers = await Promise.all( @@ -68,9 +69,9 @@ export const mediaServerRouter = createTRPCRouter({ const handleServer = async (app: ConfigAppType): Promise => { switch (app.integration?.type) { case 'jellyfin': { - const username = app.integration.properties.find((x) => x.field === 'username'); + const username = findAppProperty(app, 'username'); - if (!username || !username.value) { + if (!username) { return { appId: app.id, serverAddress: app.url, @@ -81,9 +82,9 @@ const handleServer = async (app: ConfigAppType): Promise x.field === 'password'); + const password = findAppProperty(app, 'password'); - if (!password || !password.value) { + if (!password) { return { appId: app.id, serverAddress: app.url, @@ -96,7 +97,7 @@ const handleServer = async (app: ConfigAppType): Promise x.field === 'apiKey'); + const apiKey = findAppProperty(app, 'apiKey'); - if (!apiKey || !apiKey.value) { + if (!apiKey) { return { serverAddress: app.url, sessions: [], @@ -181,7 +182,7 @@ const handleServer = async (app: ConfigAppType): Promise x.id === input.appId); - if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) { throw new TRPCError({ code: 'BAD_REQUEST', message: `App with ID "${input.appId}" could not be found.`, }); } - if (app.integration?.type === 'nzbGet') { + if (app.integration.type === 'nzbGet') { const url = new URL(app.url); const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -70,7 +71,7 @@ export const usenetRouter = createTRPCRouter({ }; } - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -110,17 +111,17 @@ export const usenetRouter = createTRPCRouter({ const app = config.apps.find((x) => x.id === input.appId); - if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) { throw new Error(`App with ID "${input.appId}" could not be found.`); } - if (app.integration?.type === 'nzbGet') { + if (app.integration.type === 'nzbGet') { const url = new URL(app.url); const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -155,7 +156,7 @@ export const usenetRouter = createTRPCRouter({ const { origin } = new URL(app.url); - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } @@ -185,7 +186,7 @@ export const usenetRouter = createTRPCRouter({ const config = getConfig(input.configName); const app = config.apps.find((x) => x.id === input.appId); - if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) { throw new Error(`App with ID "${input.appId}" could not be found.`); } @@ -194,8 +195,8 @@ export const usenetRouter = createTRPCRouter({ const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -211,7 +212,7 @@ export const usenetRouter = createTRPCRouter({ }); } - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } @@ -232,7 +233,7 @@ export const usenetRouter = createTRPCRouter({ const app = config.apps.find((x) => x.id === input.appId); - if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) { throw new Error(`App with ID "${input.appId}" could not be found.`); } @@ -241,8 +242,8 @@ export const usenetRouter = createTRPCRouter({ const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -258,7 +259,7 @@ export const usenetRouter = createTRPCRouter({ }); } - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } @@ -281,7 +282,7 @@ export const usenetRouter = createTRPCRouter({ const app = config.apps.find((x) => x.id === input.appId); - if (!app || (app.integration?.type !== 'nzbGet' && app.integration?.type !== 'sabnzbd')) { + if (!app || !checkIntegrationsType(app.integration, ['nzbGet', 'sabnzbd'])) { throw new Error(`App with ID "${input.appId}" could not be found.`); } @@ -290,8 +291,8 @@ export const usenetRouter = createTRPCRouter({ const options = { host: url.hostname, port: url.port || (url.protocol === 'https:' ? '443' : '80'), - login: app.integration.properties.find((x) => x.field === 'username')?.value ?? undefined, - hash: app.integration.properties.find((x) => x.field === 'password')?.value ?? undefined, + login: findAppProperty(app, 'username'), + hash: findAppProperty(app, 'password'), }; const nzbGet = NzbgetClient(options); @@ -340,7 +341,7 @@ export const usenetRouter = createTRPCRouter({ }; } - const apiKey = app.integration.properties.find((x) => x.field === 'apiKey')?.value; + const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { throw new Error(`API Key for app "${app.name}" is missing`); } diff --git a/src/tools/client/app-properties.ts b/src/tools/client/app-properties.ts index a53b38dbf..9965fa984 100644 --- a/src/tools/client/app-properties.ts +++ b/src/tools/client/app-properties.ts @@ -1,4 +1,27 @@ -import { ConfigAppType, IntegrationField } from '../../types/app'; +import { ConfigAppType, IntegrationField, IntegrationType } from '../../types/app'; export const findAppProperty = (app: ConfigAppType, key: IntegrationField) => app.integration?.properties.find((prop) => prop.field === key)?.value ?? ''; + +/** Checks if the type of an integration is part of the TIntegrations array with propper typing */ +export const checkIntegrationsType = < + TTest extends CheckIntegrationTypeInput, + TIntegrations extends readonly IntegrationType[] +>( + test: TTest | undefined | null, + integrations: TIntegrations +): test is CheckIntegrationType => { + if (!test) return false; + return integrations.includes(test.type!); +}; + +type CheckIntegrationTypeInput = { + type: IntegrationType | null; +}; + +type CheckIntegrationType< + TInput extends CheckIntegrationTypeInput, + TIntegrations extends readonly IntegrationType[] +> = TInput & { + type: TIntegrations[number]; +}; From afaaa1b3467916f70c4ae098306f0840b8dbd807 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 20:30:52 +0200 Subject: [PATCH 35/36] =?UTF-8?q?=F0=9F=90=9B=20Fix=20issues=20with=20test?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + vitest.config.ts | 8 +++----- yarn.lock | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6a58fd53d..af9f852d2 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "turbo": "latest", "typescript": "^5.0.4", "video.js": "^8.0.3", + "vite-tsconfig-paths": "^4.2.0", "vitest": "^0.32.0", "vitest-fetch-mock": "^0.2.2" }, diff --git a/vitest.config.ts b/vitest.config.ts index fdeb8aa25..566d1cfcc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ import react from '@vitejs/plugin-react'; +import tsconfigPaths from 'vite-tsconfig-paths'; import { configDefaults, defineConfig } from 'vitest/config'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], + plugins: [react(), tsconfigPaths()], test: { environment: 'happy-dom', coverage: { @@ -14,9 +15,6 @@ export default defineConfig({ exclude: ['.next/', '.yarn/', 'data/'], }, setupFiles: ['./tests/setupVitest.ts'], - exclude: [ - ...configDefaults.exclude, - '.next', - ], + exclude: [...configDefaults.exclude, '.next'], }, }); diff --git a/yarn.lock b/yarn.lock index 7d9f6a277..376d41e47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5663,6 +5663,13 @@ __metadata: languageName: node linkType: hard +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: adca162494a176ce9ecf4dd232f7b802956bb1966b37f60c15e49d2e7d961b66c60826366dc2649093cad5a0d69970cfa8875bd1695b5a1a2f33dcd2aa88da3c + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -5930,6 +5937,7 @@ __metadata: typescript: ^5.0.4 uuid: ^9.0.0 video.js: ^8.0.3 + vite-tsconfig-paths: ^4.2.0 vitest: ^0.32.0 vitest-fetch-mock: ^0.2.2 xml-js: ^1.6.11 @@ -9566,6 +9574,20 @@ __metadata: languageName: node linkType: hard +"tsconfck@npm:^2.1.0": + version: 2.1.1 + resolution: "tsconfck@npm:2.1.1" + peerDependencies: + typescript: ^4.3.5 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: c531525f39763cbbd7e6dbf5e29f12a7ae67eb8712816c14d06a9db6cbdc9dda9ac3cd6db07ef645f8a4cdea906447ab44e2c8679e320871cf9dd598756e8c83 + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.2 resolution: "tsconfig-paths@npm:3.14.2" @@ -10040,6 +10062,22 @@ __metadata: languageName: node linkType: hard +"vite-tsconfig-paths@npm:^4.2.0": + version: 4.2.0 + resolution: "vite-tsconfig-paths@npm:4.2.0" + dependencies: + debug: ^4.1.1 + globrex: ^0.1.2 + tsconfck: ^2.1.0 + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 73a8467de72d7ac502328454fd00c19571cd4bad2dd5982643b24718bb95e449a3f4153cfc2d58a358bfc8f37e592fb442fc10884b59ae82138c1329160cd952 + languageName: node + linkType: hard + "vite@npm:^3.0.0 || ^4.0.0": version: 4.3.9 resolution: "vite@npm:4.3.9" From 5021e8ed35eaee76ed22374adf61b708832752be Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 10 Jun 2023 20:54:53 +0200 Subject: [PATCH 36/36] =?UTF-8?q?=F0=9F=90=9B=20Fix=20issue=20with=20rss?= =?UTF-8?q?=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/api/routers/rss.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/server/api/routers/rss.ts b/src/server/api/routers/rss.ts index e60c2dd1a..35a27be6b 100644 --- a/src/server/api/routers/rss.ts +++ b/src/server/api/routers/rss.ts @@ -1,13 +1,13 @@ -import { z } from 'zod'; -import RssParser from 'rss-parser'; +import { TRPCError } from '@trpc/server'; import Consola from 'consola'; import { decode, encode } from 'html-entities'; +import RssParser from 'rss-parser'; import xss from 'xss'; -import { TRPCError } from '@trpc/server'; -import { createTRPCRouter, publicProcedure } from '../trpc'; -import { Stopwatch } from '~/tools/shared/time/stopwatch.tool'; +import { z } from 'zod'; import { getConfig } from '~/tools/config/getConfig'; +import { Stopwatch } from '~/tools/shared/time/stopwatch.tool'; import { IRssWidget } from '~/widgets/rss/RssWidgetTile'; +import { createTRPCRouter, publicProcedure } from '../trpc'; type CustomItem = { 'media:content': string; @@ -37,7 +37,7 @@ const rssFeedResultObjectSchema = z categories: z.array(z.string()).or(z.undefined()), title: z.string(), content: z.string(), - pubDate: z.string(), + pubDate: z.string().optional(), }) ), }), @@ -73,6 +73,7 @@ export const rssRouter = createTRPCRouter({ getFeedUrl(feedUrl, rssWidget.properties.dangerousAllowSanitizedItemContent) ) ); + return result; }), }); @@ -138,7 +139,7 @@ const processItemContent = (content: string, dangerousAllowSanitizedItemContent: strong: [], i: [], em: [], - img: ['src', 'width', 'height'], + img: ['src', 'width', 'height', 'alt'], br: [], small: [], ul: [],