From 003a232f5041c2ee0300f472a8da7425f912e083 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Wed, 29 Nov 2023 20:08:54 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Readd=20possibility=20to=20search?= =?UTF-8?q?=20through=20jellyseerr=20and=20overseerr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../en/settings/customization/general.json | 4 + .../en/settings/customization/search.json | 6 + .../Customize/Search/SearchCustomization.tsx | 105 ++++++++++++++++++ src/components/layout/header/Search.tsx | 6 +- .../layout/header/Search/MovieModal.tsx | 8 -- src/modules/common/MediaDisplay.tsx | 44 ++------ src/pages/board/[slug]/customize.tsx | 38 +++++-- src/server/api/root.ts | 2 + src/server/api/routers/board.ts | 74 ++++++++---- src/server/api/routers/integration.ts | 13 +++ src/server/api/routers/overseerr.ts | 75 +++++++------ src/server/db/schema.ts | 4 +- src/validations/boards.ts | 9 +- 13 files changed, 276 insertions(+), 112 deletions(-) create mode 100644 public/locales/en/settings/customization/search.json create mode 100644 src/components/Board/Customize/Search/SearchCustomization.tsx create mode 100644 src/server/api/routers/integration.ts diff --git a/public/locales/en/settings/customization/general.json b/public/locales/en/settings/customization/general.json index d258eb1b9..46c79f66e 100644 --- a/public/locales/en/settings/customization/general.json +++ b/public/locales/en/settings/customization/general.json @@ -16,6 +16,10 @@ "access": { "name": "Access", "description": "Configure who has access to your board" + }, + "search": { + "name": "Search", + "description": "Customize the search experience on your board" } } } \ No newline at end of file diff --git a/public/locales/en/settings/customization/search.json b/public/locales/en/settings/customization/search.json new file mode 100644 index 000000000..83e7fe275 --- /dev/null +++ b/public/locales/en/settings/customization/search.json @@ -0,0 +1,6 @@ +{ + "mediaIntegrations": { + "label": "Search media integrations", + "description": "Selected integrations will be available in the search of this board" + } +} \ No newline at end of file diff --git a/src/components/Board/Customize/Search/SearchCustomization.tsx b/src/components/Board/Customize/Search/SearchCustomization.tsx new file mode 100644 index 000000000..38a573fc4 --- /dev/null +++ b/src/components/Board/Customize/Search/SearchCustomization.tsx @@ -0,0 +1,105 @@ +import { + Avatar, + Box, + CloseButton, + Group, + MultiSelect, + MultiSelectValueProps, + Stack, + Text, + rem, +} from '@mantine/core'; +import { useTranslation } from 'next-i18next'; +import { forwardRef } from 'react'; +import { IntegrationType, integrationTypes } from '~/server/db/items'; +import { Integration } from '~/server/db/schema'; +import { RouterOutputs } from '~/utils/api'; + +import { useBoardCustomizationFormContext } from '../form'; + +interface IntegrationCustomizationProps { + allMediaIntegrations: RouterOutputs['integration']['allMedia']; +} + +export const SearchCustomization = ({ allMediaIntegrations }: IntegrationCustomizationProps) => { + const { t } = useTranslation('settings/customization/search'); + const form = useBoardCustomizationFormContext(); + + return ( + + ({ + value: x.id, + label: x.name, + sort: x.sort, + }))} + /> + + ); +}; + +interface ItemProps extends React.ComponentPropsWithoutRef<'div'> { + sort: IntegrationType; + label: string; +} + +const IntegrationSelectItem = forwardRef( + ({ label, sort, ...others }: ItemProps, ref) => { + return ( +
+ + + + {label} + +
+ ); + } +); + +const IntegrationSelectValue = + (integrations: Integration[]) => + ({ + value, + label, + onRemove, + classNames, + ...others + }: MultiSelectValueProps & { value: string }) => { + const current = integrations.find((x) => x.id === value); + return ( +
+ ({ + display: 'flex', + cursor: 'default', + alignItems: 'center', + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white, + border: `${rem(1)} solid ${ + theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[4] + }`, + paddingLeft: theme.spacing.xs, + borderRadius: theme.radius.sm, + })} + > + + + + {label} + + +
+ ); + }; diff --git a/src/components/layout/header/Search.tsx b/src/components/layout/header/Search.tsx index 25a598cf9..5a4457bbc 100644 --- a/src/components/layout/header/Search.tsx +++ b/src/components/layout/header/Search.tsx @@ -49,10 +49,7 @@ export const Search = ({ isMobile, autoFocus }: SearchProps) => { ) .filter( (engine) => - engine.sort !== 'movie' || - board?.sections.some((section) => - section.items.some((x) => x.kind === 'app' && x.integration?.type === engine.value) - ) + engine.sort !== 'movie' || board?.mediaIntegrations.some((x) => x.sort === engine.value) ) .map((engine) => ({ ...engine, @@ -61,6 +58,7 @@ export const Search = ({ isMobile, autoFocus }: SearchProps) => { query: search, }), })); + const data = [...apps, ...engines]; return ( diff --git a/src/components/layout/header/Search/MovieModal.tsx b/src/components/layout/header/Search/MovieModal.tsx index eafd17b0e..d017421e8 100644 --- a/src/components/layout/header/Search/MovieModal.tsx +++ b/src/components/layout/header/Search/MovieModal.tsx @@ -131,14 +131,6 @@ const MovieDisplay = ({ movie, type }: MovieDisplayProps) => { const { t } = useTranslation('modules/common-media-cards'); const [requestModalOpened, requestModal] = useDisclosure(false); - /*const service = config.apps.find((service) => service.integration.type === type); - const mediaUrl = movie.mediaInfo?.plexUrl ?? movie.mediaInfo?.mediaUrl; - const serviceUrl = service?.behaviour.externalUrl ?? service?.url; - const externalUrl = new URL( - `${movie.mediaType}/${movie.id}`, - serviceUrl ?? 'https://www.themoviedb.org' - );*/ - return ( diff --git a/src/modules/common/MediaDisplay.tsx b/src/modules/common/MediaDisplay.tsx index 860fc0c99..9c4e84b41 100644 --- a/src/modules/common/MediaDisplay.tsx +++ b/src/modules/common/MediaDisplay.tsx @@ -26,41 +26,6 @@ export interface IMedia { [key: string]: any; } -export function OverseerrMediaDisplay(props: any) { - const { media }: { media: Result } = props; - const { config } = useConfigContext(); - - if (!config) { - return null; - } - - const service = config.apps.find( - (service) => - service.integration.type === 'overseerr' || service.integration.type === 'jellyseerr' - ); - - return ( - - ); -} - export function ReadarrMediaDisplay(props: any) { const { media }: { media: any } = props; const { config } = useConfigContext(); @@ -192,7 +157,14 @@ export function MediaDisplay({ media }: { media: IMedia }) { return ( - + diff --git a/src/pages/board/[slug]/customize.tsx b/src/pages/board/[slug]/customize.tsx index 0c8e27836..16a63a123 100644 --- a/src/pages/board/[slug]/customize.tsx +++ b/src/pages/board/[slug]/customize.tsx @@ -20,6 +20,7 @@ import { IconChartCandle, IconCheck, IconLock, + IconSearch, IconX, TablerIconsProps, } from '@tabler/icons-react'; @@ -30,18 +31,20 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { ReactNode } from 'react'; import { z } from 'zod'; +import type generalSettingsTranslations from '~/../public/locales/en/settings/customization/general.json'; import { AccessCustomization } from '~/components/Board/Customize/Access/AccessCustomization'; import { AppearanceCustomization } from '~/components/Board/Customize/Appearance/AppearanceCustomization'; import { NetworkCustomization } from '~/components/Board/Customize/Network/NetworkCustomization'; import { PageMetadataCustomization } from '~/components/Board/Customize/PageMetadata/PageMetadataCustomization'; +import { SearchCustomization } from '~/components/Board/Customize/Search/SearchCustomization'; import { BoardCustomizationFormProvider, useBoardCustomizationForm, } from '~/components/Board/Customize/form'; import { useBoardLink } from '~/components/layout/Templates/BoardLayout'; import { MainLayout } from '~/components/layout/Templates/MainLayout'; -import { createTrpcServersideHelpers } from '~/server/api/helper'; import { boardRouter } from '~/server/api/routers/board'; +import { integrationRouter } from '~/server/api/routers/integration'; import { getServerAuthSession } from '~/server/auth'; import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; @@ -54,22 +57,27 @@ const notificationId = 'board-customization-notification'; export default function CustomizationPage({ initialBoard, + allMediaIntegrations, }: InferGetServerSidePropsType) { const query = useRouter().query as { slug: string }; const utils = api.useContext(); const { - data: board, + data: queryBoard, isError, error, } = api.boards.byNameSimple.useQuery( { boardName: query.slug }, { - initialData: initialBoard, refetchOnMount: false, + refetchOnReconnect: false, useErrorBoundary: false, suspense: false, + enabled: typeof window !== 'undefined', // Disable on server-side so it is not cached with an old result. } ); + + const board = queryBoard ?? initialBoard; // Initialdata property is not working because it somehow ignores the enabled property. + const { mutateAsync: updateCustomization, isLoading } = api.boards.updateCustomization.useMutation(); const { i18nZodResolver } = useI18nZodResolver(); @@ -99,6 +107,9 @@ export default function CustomizationPage({ logoSrc: board.logoImageUrl ?? '', faviconSrc: board.faviconImageUrl ?? '', }, + search: { + mediaIntegrations: board.mediaIntegrations.map(({ id }) => id), + }, }, validate: i18nZodResolver(boardCustomizationSchema), validateInputOnChange: true, @@ -250,6 +261,10 @@ export default function CustomizationPage({ + + + + @@ -264,7 +279,7 @@ export default function CustomizationPage({ } type SectionTitleProps = { - type: 'network' | 'pageMetadata' | 'appereance' | 'access'; + type: keyof (typeof generalSettingsTranslations)['accordeon']; icon: (props: TablerIconsProps) => ReactNode; }; @@ -305,20 +320,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => return result; } - const helpers = await createTrpcServersideHelpers({ req: context.req, res: context.res }); - const caller = boardRouter.createCaller({ + const boardCaller = boardRouter.createCaller({ session: session, cookies: context.req.cookies, headers: context.req.headers, }); - const board = await caller.byNameSimple({ boardName: routeParams.data.slug }); + const board = await boardCaller.byNameSimple({ boardName: routeParams.data.slug }); if (!board) { return { notFound: true, }; } + const integrationCaller = integrationRouter.createCaller({ + session: session, + cookies: context.req.cookies, + headers: context.req.headers, + }); + + const allMediaIntegrations = await integrationCaller.allMedia(); + const translations = await getServerSideTranslations( [ 'boards/customize', @@ -329,6 +351,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => 'settings/customization/opacity-selector', 'settings/customization/gridstack', 'settings/customization/access', + 'settings/customization/search', ], context.locale, context.req, @@ -341,6 +364,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => secondaryColor: board.secondaryColor, primaryShade: board.primaryShade, initialBoard: board, + allMediaIntegrations, ...translations, }, }; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 7cc34e135..63e2cbcc1 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -9,6 +9,7 @@ import { dnsHoleRouter } from './routers/dns-hole/router'; import { dockerRouter } from './routers/docker/router'; import { downloadRouter } from './routers/download'; import { iconRouter } from './routers/icon'; +import { integrationRouter } from './routers/integration'; import { inviteRouter } from './routers/invite/invite-router'; import { layoutsRouter } from './routers/layout/layout.router'; import { mediaRequestsRouter } from './routers/media-request'; @@ -49,6 +50,7 @@ export const rootRouter = createTRPCRouter({ password: passwordRouter, notebook: notebookRouter, layouts: layoutsRouter, + integration: integrationRouter, }); // export type definition of API diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts index 150af05cf..3f2352b4e 100644 --- a/src/server/api/routers/board.ts +++ b/src/server/api/routers/board.ts @@ -9,6 +9,7 @@ import { Integration, appStatusCodes, apps, + boardIntegrations, boards, items, layoutItems, @@ -262,26 +263,59 @@ export const boardRouter = createTRPCRouter({ }); } - await db - .update(boards) - .set({ - allowGuests: input.customization.access.allowGuests, - isPingEnabled: input.customization.network.pingsEnabled, - appOpacity: input.customization.appearance.opacity, - backgroundImageUrl: input.customization.appearance.backgroundSrc, - backgroundImageAttachment: input.customization.appearance.backgroundImageAttachment, - backgroundImageSize: input.customization.appearance.backgroundImageSize, - backgroundImageRepeat: input.customization.appearance.backgroundImageRepeat, - primaryColor: input.customization.appearance.primaryColor, - secondaryColor: input.customization.appearance.secondaryColor, - customCss: input.customization.appearance.customCss, - pageTitle: input.customization.pageMetadata.pageTitle, - metaTitle: input.customization.pageMetadata.metaTitle, - logoImageUrl: input.customization.pageMetadata.logoSrc, - faviconImageUrl: input.customization.pageMetadata.faviconSrc, - primaryShade: input.customization.appearance.shade, - }) - .where(eq(boards.id, dbBoard.id)); + const dbIntegrations = await db.query.boardIntegrations.findMany({ + where: eq(boardIntegrations.boardId, dbBoard.id), + }); + + const inputIntegrations = input.customization.search.mediaIntegrations; + + const newIntegrations = inputIntegrations.filter((id) => + dbIntegrations.every((y) => y.integrationId !== id) + ); + + const removedIntegrations = dbIntegrations.filter((x) => + inputIntegrations.every((y) => y !== x.integrationId) + ); + + await db.transaction(async (tx) => { + await tx + .update(boards) + .set({ + allowGuests: input.customization.access.allowGuests, + isPingEnabled: input.customization.network.pingsEnabled, + appOpacity: input.customization.appearance.opacity, + backgroundImageUrl: input.customization.appearance.backgroundSrc, + backgroundImageAttachment: input.customization.appearance.backgroundImageAttachment, + backgroundImageSize: input.customization.appearance.backgroundImageSize, + backgroundImageRepeat: input.customization.appearance.backgroundImageRepeat, + primaryColor: input.customization.appearance.primaryColor, + secondaryColor: input.customization.appearance.secondaryColor, + customCss: input.customization.appearance.customCss, + pageTitle: input.customization.pageMetadata.pageTitle, + metaTitle: input.customization.pageMetadata.metaTitle, + logoImageUrl: input.customization.pageMetadata.logoSrc, + faviconImageUrl: input.customization.pageMetadata.faviconSrc, + primaryShade: input.customization.appearance.shade, + }) + .where(eq(boards.id, dbBoard.id)); + + if (newIntegrations.length > 0) { + await tx.insert(boardIntegrations).values( + newIntegrations.map((id) => ({ + boardId: dbBoard.id, + integrationId: id, + })) + ); + } + if (removedIntegrations.length > 0) { + await tx.delete(boardIntegrations).where( + inArray( + boardIntegrations.integrationId, + removedIntegrations.map((x) => x.integrationId) + ) + ); + } + }); return dbBoard; }), diff --git a/src/server/api/routers/integration.ts b/src/server/api/routers/integration.ts new file mode 100644 index 000000000..876bc7776 --- /dev/null +++ b/src/server/api/routers/integration.ts @@ -0,0 +1,13 @@ +import { inArray } from 'drizzle-orm'; +import { db } from '~/server/db'; +import { integrations } from '~/server/db/schema'; + +import { adminProcedure, createTRPCRouter } from '../trpc'; + +export const integrationRouter = createTRPCRouter({ + allMedia: adminProcedure.query(async () => { + return await db.query.integrations.findMany({ + where: inArray(integrations.sort, ['jellyseerr', 'overseerr']), + }); + }), +}); diff --git a/src/server/api/routers/overseerr.ts b/src/server/api/routers/overseerr.ts index 0ed7d20c3..63a7a3d46 100644 --- a/src/server/api/routers/overseerr.ts +++ b/src/server/api/routers/overseerr.ts @@ -1,11 +1,14 @@ import { TRPCError } from '@trpc/server'; import axios from 'axios'; import Consola from 'consola'; +import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { MovieResult } from '~/modules/overseerr/Movie'; import { Result } from '~/modules/overseerr/SearchResult'; import { TvShowResult } from '~/modules/overseerr/TvShow'; -import { getIntegrations, getSecret } from '~/server/db/queries/integrations'; +import { db } from '~/server/db'; +import { getSecret } from '~/server/db/queries/integrations'; +import { boardIntegrations, integrationSecrets, integrations } from '~/server/db/schema'; import { getConfig } from '~/tools/config/getConfig'; import { createTRPCRouter, publicProcedure } from '../trpc'; @@ -20,47 +23,51 @@ export const overseerrRouter = createTRPCRouter({ limit: z.number().default(10), }) ) - .query(async ({ input, ctx }) => { - const integrations = await getIntegrations( - input.boardId, - [input.integration], - ctx.session?.user - ); + .query(async ({ input }) => { + const dbIntegration = await db + .select() + .from(boardIntegrations) + .leftJoin(integrations, eq(integrations.id, boardIntegrations.integrationId)) + .leftJoin(integrationSecrets, eq(integrationSecrets.integrationId, integrations.id)) + .where( + and( + eq(boardIntegrations.boardId, input.boardId), + eq(integrations.sort, input.integration) + ) + ) + .get(); - if (input.query === '' || input.query === undefined || integrations.length === 0) { + if (!dbIntegration || !dbIntegration.integration || !dbIntegration.integration_secret) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'No integration found', + }); + } + + if (input.query === '' || input.query === undefined) { return []; } - const resultsFromIntegrationApi = await Promise.allSettled>( - integrations.map(async (integration) => { - const url = new URL(integration.url); - return await axios - .get(`${url.origin}/api/v1/search?query=${input.query}`, { - headers: { - // Set X-Api-Key to the value of the API key - 'X-Api-Key': getSecret(integration, 'apiKey'), - }, - }) - .then((res) => - res.data.results.map((x: Result) => ({ ...x, integrationId: integration.id })) - ) - .catch((err) => { - Consola.error(err); - return []; - }); + const integration = { + ...dbIntegration.integration, + secrets: [dbIntegration.integration_secret!], + }; + const url = new URL(integration.url); + const results: Result[] = await axios + .get(`${url.origin}/api/v1/search?query=${input.query}`, { + headers: { + // Set X-Api-Key to the value of the API key + 'X-Api-Key': getSecret(integration, 'apiKey'), + }, }) - ); - - const results = resultsFromIntegrationApi - .filter( - (x): x is PromiseFulfilledResult<(Result & { integrationId: string })[]> => - x.status === 'fulfilled' - ) - .flatMap((x) => x.value); + .then((res) => res.data) + .catch((err) => { + Consola.error(err); + return []; + }); return results.slice(0, input.limit).map((result) => ({ id: result.id, - integrationId: result.integrationId, imageUrl: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${ result.posterPath ?? result.backdropPath }`, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index c73446c7c..200738462 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -179,8 +179,8 @@ export const integrationSecrets = sqliteTable( export const boardIntegrations = sqliteTable( 'board_integration', { - boardId: text('board_id'), - integrationId: text('integration_id'), + boardId: text('board_id').notNull(), + integrationId: text('integration_id').notNull(), }, (boardIntegration) => ({ compoundKey: primaryKey(boardIntegration.boardId, boardIntegration.integrationId), diff --git a/src/validations/boards.ts b/src/validations/boards.ts index 2483b23a5..98ff9410a 100644 --- a/src/validations/boards.ts +++ b/src/validations/boards.ts @@ -1,6 +1,10 @@ import { DEFAULT_THEME, MANTINE_COLORS, MantineColor } from '@mantine/core'; import { z } from 'zod'; -import { BackgroundImageAttachment, BackgroundImageRepeat, BackgroundImageSize } from '~/types/settings'; +import { + BackgroundImageAttachment, + BackgroundImageRepeat, + BackgroundImageSize, +} from '~/types/settings'; export const createBoardSchemaValidation = z.object({ name: z.string().min(2).max(25), @@ -37,6 +41,9 @@ export const boardCustomizationSchema = z.object({ opacity: z.number().min(10).max(100), customCss: z.string(), }), + search: z.object({ + mediaIntegrations: z.array(z.string()), + }), }); export const boardNameSchema = z.string().regex(/^[a-zA-Z0-9-_]+$/);