Readd possibility to search through jellyseerr and overseerr

This commit is contained in:
Meier Lukas
2023-11-29 20:08:54 +01:00
parent 8caf0b7932
commit 003a232f50
13 changed files with 276 additions and 112 deletions

View File

@@ -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"
}
}
}

View File

@@ -0,0 +1,6 @@
{
"mediaIntegrations": {
"label": "Search media integrations",
"description": "Selected integrations will be available in the search of this board"
}
}

View File

@@ -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 (
<Stack spacing="sm">
<MultiSelect
{...form.getInputProps('search.mediaIntegrations')}
label={t('mediaIntegrations.label')}
description={t('mediaIntegrations.description')}
searchable
valueComponent={IntegrationSelectValue(allMediaIntegrations)}
itemComponent={IntegrationSelectItem}
data={allMediaIntegrations.map((x) => ({
value: x.id,
label: x.name,
sort: x.sort,
}))}
/>
</Stack>
);
};
interface ItemProps extends React.ComponentPropsWithoutRef<'div'> {
sort: IntegrationType;
label: string;
}
const IntegrationSelectItem = forwardRef<HTMLDivElement, ItemProps>(
({ label, sort, ...others }: ItemProps, ref) => {
return (
<div ref={ref} {...others}>
<Group noWrap>
<Avatar size={20} src={integrationTypes[sort].iconUrl} />
<Text>{label}</Text>
</Group>
</div>
);
}
);
const IntegrationSelectValue =
(integrations: Integration[]) =>
({
value,
label,
onRemove,
classNames,
...others
}: MultiSelectValueProps & { value: string }) => {
const current = integrations.find((x) => x.id === value);
return (
<div {...others}>
<Box
sx={(theme) => ({
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,
})}
>
<Box mr={10}>
<Avatar size="xs" src={integrationTypes[current!.sort].iconUrl} />
</Box>
<Box sx={{ lineHeight: 1, fontSize: rem(12) }}>{label}</Box>
<CloseButton
onMouseDown={onRemove}
variant="transparent"
size={22}
iconSize={14}
tabIndex={-1}
/>
</Box>
</div>
);
};

View File

@@ -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 (

View File

@@ -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 (
<Card withBorder>
<Group noWrap style={{ maxHeight: 250 }} p={0} m={0} spacing="xs" align="stretch">

View File

@@ -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 (
<MediaDisplay
media={{
...media,
genres: [],
overview: media.overview ?? '',
title: media.title ?? media.name ?? media.originalName,
poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`,
seasonNumber: media.mediaInfo?.seasons.length,
episodetitle: media.title,
plexUrl: media.mediaInfo?.plexUrl ?? media.mediaInfo?.mediaUrl,
voteAverage: media.voteAverage?.toString(),
overseerrResult: media,
overseerrId: `${
service?.behaviour.externalUrl ? service.behaviour.externalUrl : service?.url
}/${media.mediaType}/${media.id}`,
type: 'overseer',
}}
/>
);
}
export function ReadarrMediaDisplay(props: any) {
const { media }: { media: any } = props;
const { config } = useConfigContext();
@@ -192,7 +157,14 @@ export function MediaDisplay({ media }: { media: IMedia }) {
return (
<Group noWrap style={{ maxHeight: 250, maxWidth: 400 }} p={0} m={0} spacing="xs">
<Image src={media.poster?? media.altPoster} height={200} width={150} radius="md" fit="cover" withPlaceholder/>
<Image
src={media.poster ?? media.altPoster}
height={200}
width={150}
radius="md"
fit="cover"
withPlaceholder
/>
<Stack justify="space-around">
<Stack spacing="sm">
<Text lineClamp={2}>

View File

@@ -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<typeof getServerSideProps>) {
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({
</Stack>
</Grid.Col>
</Grid>
<Stack spacing="xs">
<SectionTitle type="search" icon={IconSearch} />
<SearchCustomization allMediaIntegrations={allMediaIntegrations} />
</Stack>
<Stack spacing="xs">
<SectionTitle type="appereance" icon={IconBrush} />
<AppearanceCustomization />
@@ -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,
},
};

View File

@@ -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

View File

@@ -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;
}),

View File

@@ -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']),
});
}),
});

View File

@@ -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<Promise<Result[]>>(
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
}`,

View File

@@ -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),

View File

@@ -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-_]+$/);