Avancement on Overseerr integration

This commit is contained in:
ajnart
2022-05-29 21:39:57 +02:00
parent 596db5fefc
commit 1de20d1583
7 changed files with 153 additions and 106 deletions

View File

@@ -14,9 +14,10 @@ import {
Text, Text,
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { IconApps as Apps } from '@tabler/icons'; import { IconApps as Apps } from '@tabler/icons';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { useDebouncedValue } from '@mantine/hooks';
import { useConfig } from '../../tools/state'; import { useConfig } from '../../tools/state';
import { ServiceTypeList } from '../../tools/types'; import { ServiceTypeList } from '../../tools/types';
@@ -134,6 +135,14 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
}, },
}); });
const [debounced, cancel] = useDebouncedValue(form.values.name, 250);
useEffect(() => {
if (form.values.name !== debounced) return;
MatchIcon(form.values.name, form);
MatchService(form.values.name, form);
MatchPort(form.values.name, form);
}, [debounced]);
// Try to set const hostname to new URL(form.values.url).hostname) // Try to set const hostname to new URL(form.values.url).hostname)
// If it fails, set it to the form.values.url // If it fails, set it to the form.values.url
let hostname = form.values.url; let hostname = form.values.url;
@@ -186,14 +195,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
required required
label="Service name" label="Service name"
placeholder="Plex" placeholder="Plex"
value={form.values.name} {...form.getInputProps('name')}
onChange={(event) => {
form.setFieldValue('name', event.currentTarget.value);
MatchIcon(event.currentTarget.value, form);
MatchService(event.currentTarget.value, form);
MatchPort(event.currentTarget.value, form);
}}
error={form.errors.name && 'Invalid icon url'}
/> />
<TextInput <TextInput

View File

@@ -1,4 +1,15 @@
import { Image, Group, Title, Badge, Text, ActionIcon, Anchor, ScrollArea, Tooltip, GroupProps } from '@mantine/core'; import {
Image,
Group,
Title,
Badge,
Text,
ActionIcon,
Anchor,
ScrollArea,
Tooltip,
GroupProps,
} from '@mantine/core';
import { IconLink, IconPlayerPlay } from '@tabler/icons'; import { IconLink, IconPlayerPlay } from '@tabler/icons';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { serviceItem } from '../../../tools/types'; import { serviceItem } from '../../../tools/types';
@@ -46,6 +57,11 @@ export function MediaDisplay(
</Anchor> </Anchor>
</Tooltip> </Tooltip>
)} )}
{media.plexUrl && (
<Badge color="green" size="lg">
Available on Plex
</Badge>
)}
{media.imdbId && ( {media.imdbId && (
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank"> <Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
<ActionIcon> <ActionIcon>

View File

@@ -1,62 +1,8 @@
import { Card } from '@mantine/core'; import { Card } from '@mantine/core';
import { MediaDisplay } from '../calendar/MediaDisplay'; import { MediaDisplay } from '../calendar/MediaDisplay';
export interface OverseerrMedia {
id: number;
firstAirDate: string;
genreIds: number[];
mediaType: string;
name: string;
originCountry: string[];
originalLanguage: string;
originalName: string;
overview: string;
popularity: number;
voteAverage: number;
voteCount: number;
backdropPath: string;
posterPath: string;
mediaInfo: MediaInfo;
}
export interface MediaInfo {
downloadStatus: any[];
downloadStatus4k: any[];
id: number;
mediaType: string;
tmdbId: number;
tvdbId: number;
imdbId: null;
status: number;
status4k: number;
createdAt: string;
updatedAt: string;
lastSeasonChange: string;
mediaAddedAt: string;
serviceId: number;
serviceId4k: null;
externalServiceId: number;
externalServiceId4k: null;
externalServiceSlug: string;
externalServiceSlug4k: null;
ratingKey: string;
ratingKey4k: null;
seasons: Season[];
plexUrl: string;
serviceUrl: string;
}
export interface Season {
id: number;
seasonNumber: number;
status: number;
status4k: number;
createdAt: string;
updatedAt: string;
}
export default function OverseerrMediaDisplay(props: any) { export default function OverseerrMediaDisplay(props: any) {
const { media }: { media: OverseerrMedia } = props; const { media }: { media: any } = props;
return ( return (
<Card shadow="xl" withBorder> <Card shadow="xl" withBorder>
<MediaDisplay <MediaDisplay
@@ -64,13 +10,13 @@ export default function OverseerrMediaDisplay(props: any) {
width: 600, width: 600,
}} }}
media={{ media={{
title: media.name, title: media.name ?? media.originalTitle,
seasonNumber: media.mediaInfo.seasons.length + 1,
overview: media.overview, overview: media.overview,
plexUrl: media.mediaInfo.plexUrl,
imdbId: media.mediaInfo.imdbId,
poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`, poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`,
genres: [`score: ${media.voteAverage}/10`], genres: [`score: ${media.voteAverage}/10`],
seasonNumber: media.mediaInfo?.seasons.length,
plexUrl: media.mediaInfo?.plexUrl,
imdbId: media.mediaInfo?.imdbId,
}} }}
/> />
</Card> </Card>

View File

@@ -1,13 +1,21 @@
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core'; import {
import { useForm, useHotkeys } from '@mantine/hooks'; Kbd,
import { useRef, useState } from 'react'; createStyles,
Text,
Popover,
TextInput,
} from '@mantine/core';
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
import { useEffect, useRef, useState } from 'react';
import { import {
IconSearch as Search, IconSearch as Search,
IconBrandYoutube as BrandYoutube, IconBrandYoutube as BrandYoutube,
IconDownload as Download, IconDownload as Download,
} from '@tabler/icons'; } from '@tabler/icons';
import axios from 'axios';
import { useConfig } from '../../../tools/state'; import { useConfig } from '../../../tools/state';
import { IModule } from '../modules'; import { IModule } from '../modules';
import OverseerrMediaDisplay from '../overseerr/OverseerrMediaDisplay';
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
hide: { hide: {
@@ -29,11 +37,35 @@ export const SearchModule: IModule = {
export default function SearchBar(props: any) { export default function SearchBar(props: any) {
const { config, setConfig } = useConfig(); const { config, setConfig } = useConfig();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [results, setOpenedResults] = useState(false);
const [icon, setIcon] = useState(<Search />); const [icon, setIcon] = useState(<Search />);
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q='; const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
const textInput = useRef<HTMLInputElement>(); const textInput = useRef<HTMLInputElement>();
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]); // Find a service with the type of 'Overseerr'
const service = config.services.find((s) => s.type === 'Overseerr');
const form = useForm({
initialValues: {
query: '',
},
});
const [debounced, cancel] = useDebouncedValue(form.values.query, 250);
const [data, setData] = useState([]);
useEffect(() => {
if (form.values.query !== debounced || form.values.query === '') return;
setOpened(false);
setOpenedResults(true);
if (service) {
const serviceUrl = new URL(service.url);
axios
.post(`/api/modules/overseerr?query=${form.values.query}`, {
service,
})
.then((res) => setData(res.data.results ?? []));
}
}, [debounced]);
useHotkeys([['ctrl+K', () => textInput.current && textInput.current.focus()]]);
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const rightSection = ( const rightSection = (
<div className={classes.hide}> <div className={classes.hide}>
@@ -43,12 +75,6 @@ export default function SearchBar(props: any) {
</div> </div>
); );
const form = useForm({
initialValues: {
query: '',
},
});
// If enabled modules doesn't contain the module, return null // If enabled modules doesn't contain the module, return null
// If module in enabled // If module in enabled
@@ -57,6 +83,7 @@ export default function SearchBar(props: any) {
return null; return null;
} }
// Data with label as item.name
return ( return (
<form <form
onChange={() => { onChange={() => {
@@ -89,36 +116,46 @@ export default function SearchBar(props: any) {
})} })}
> >
<Popover <Popover
opened={opened} opened={results}
position="bottom"
placement="start"
width={260}
withArrow
radius="md"
trapFocus={false}
transition="pop-bottom-right"
onFocusCapture={() => setOpened(true)}
onBlurCapture={() => setOpened(false)}
target={ target={
<TextInput <Popover
variant="filled" opened={opened}
icon={icon} position="bottom"
ref={textInput} placement="start"
rightSectionWidth={90} width={260}
rightSection={rightSection} withArrow
radius="md" radius="md"
size="md" trapFocus={false}
styles={{ rightSection: { pointerEvents: 'none' } }} transition="pop-bottom-right"
placeholder="Search the web..." onFocusCapture={() => setOpened(true)}
{...props} onBlurCapture={() => setOpened(false)}
{...form.getInputProps('query')} target={
/> <TextInput
variant="filled"
icon={icon}
ref={textInput}
rightSectionWidth={90}
rightSection={rightSection}
radius="md"
size="md"
styles={{ rightSection: { pointerEvents: 'none' } }}
placeholder="Search the web..."
{...props}
{...form.getInputProps('query')}
/>
}
>
<Text>
tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on
YouTube or for a Torrent respectively.
</Text>
</Popover>
} }
> >
<Text> {/* Loop on the first 5 items of data */}
tip: Use the prefixes <b>!yt</b> and <b>!t</b> in front of your query to search on YouTube {data.slice(0, 5).map((item, index) => (
or for a Torrent respectively. <OverseerrMediaDisplay key={index} media={item} />
</Text> ))}
</Popover> </Popover>
</form> </form>
); );

View File

@@ -0,0 +1,41 @@
import axios from 'axios';
import { NextApiRequest, NextApiResponse } from 'next';
import { serviceItem } from '../../../tools/types';
async function Post(req: NextApiRequest, res: NextApiResponse) {
const { service }: { service: serviceItem } = req.body;
const { query } = req.query;
// If query is an empty string, return an empty array
if (query === '') {
return res.status(200).json([]);
}
if (!service || !query || !service.apiKey) {
return res.status(400).json({
error: 'Wrong request',
});
}
const serviceUrl = new URL(service.url);
const data = await axios
.get(`${serviceUrl.origin}/api/v1/search?query=${query}`, {
headers: {
// Set X-Api-Key to the value of the API key
'X-Api-Key': service.apiKey,
},
})
.then((res) => res.data);
// Get login, password and url from the body
res.status(200).json(
data,
);
}
export default async (req: NextApiRequest, res: NextApiResponse) => {
// Filter out if the reuqest is a POST or a GET
if (req.method === 'POST') {
return Post(req, res);
}
return res.status(405).json({
statusCode: 405,
message: 'Method not allowed',
});
};

View File

@@ -3,11 +3,14 @@ import OverseerrMediaDisplay, {
OverseerrMedia, OverseerrMedia,
} from '../components/modules/overseerr/OverseerrMediaDisplay'; } from '../components/modules/overseerr/OverseerrMediaDisplay';
import media from '../components/modules/overseerr/example.json'; import media from '../components/modules/overseerr/example.json';
import { ModuleWrapper } from '../components/modules/moduleWrapper';
import { SearchModule } from '../components/modules';
export default function TryOverseerr() { export default function TryOverseerr() {
return ( return (
<Group direction="column"> <Group direction="column">
<OverseerrMediaDisplay media={media} /> <OverseerrMediaDisplay media={media} />
<ModuleWrapper module={SearchModule} />
</Group> </Group>
); );
} }

View File

@@ -31,6 +31,7 @@ export const ServiceTypeList = [
'Readarr', 'Readarr',
'Sonarr', 'Sonarr',
'qBittorrent', 'qBittorrent',
'Overseerr',
]; ];
export type ServiceType = export type ServiceType =
| 'Other' | 'Other'
@@ -41,7 +42,8 @@ export type ServiceType =
| 'Radarr' | 'Radarr'
| 'Readarr' | 'Readarr'
| 'Sonarr' | 'Sonarr'
| 'qBittorrent'; | 'qBittorrent'
| 'Overseerr';
export interface serviceItem { export interface serviceItem {
id: string; id: string;