mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 07:55:52 +01:00
✨ Avancement on Overseerr integration
This commit is contained in:
@@ -14,9 +14,10 @@ import {
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconApps as Apps } from '@tabler/icons';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { useConfig } from '../../tools/state';
|
||||
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)
|
||||
// If it fails, set it to the form.values.url
|
||||
let hostname = form.values.url;
|
||||
@@ -186,14 +195,7 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
||||
required
|
||||
label="Service name"
|
||||
placeholder="Plex"
|
||||
value={form.values.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'}
|
||||
{...form.getInputProps('name')}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
|
||||
@@ -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 { useConfig } from '../../../tools/state';
|
||||
import { serviceItem } from '../../../tools/types';
|
||||
@@ -46,6 +57,11 @@ export function MediaDisplay(
|
||||
</Anchor>
|
||||
</Tooltip>
|
||||
)}
|
||||
{media.plexUrl && (
|
||||
<Badge color="green" size="lg">
|
||||
Available on Plex
|
||||
</Badge>
|
||||
)}
|
||||
{media.imdbId && (
|
||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
||||
<ActionIcon>
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
import { Card } from '@mantine/core';
|
||||
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) {
|
||||
const { media }: { media: OverseerrMedia } = props;
|
||||
const { media }: { media: any } = props;
|
||||
return (
|
||||
<Card shadow="xl" withBorder>
|
||||
<MediaDisplay
|
||||
@@ -64,13 +10,13 @@ export default function OverseerrMediaDisplay(props: any) {
|
||||
width: 600,
|
||||
}}
|
||||
media={{
|
||||
title: media.name,
|
||||
seasonNumber: media.mediaInfo.seasons.length + 1,
|
||||
title: media.name ?? media.originalTitle,
|
||||
overview: media.overview,
|
||||
plexUrl: media.mediaInfo.plexUrl,
|
||||
imdbId: media.mediaInfo.imdbId,
|
||||
poster: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${media.posterPath}`,
|
||||
genres: [`score: ${media.voteAverage}/10`],
|
||||
seasonNumber: media.mediaInfo?.seasons.length,
|
||||
plexUrl: media.mediaInfo?.plexUrl,
|
||||
imdbId: media.mediaInfo?.imdbId,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { TextInput, Kbd, createStyles, Text, Popover } from '@mantine/core';
|
||||
import { useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Kbd,
|
||||
createStyles,
|
||||
Text,
|
||||
Popover,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useForm, useHotkeys } from '@mantine/hooks';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
IconSearch as Search,
|
||||
IconBrandYoutube as BrandYoutube,
|
||||
IconDownload as Download,
|
||||
} from '@tabler/icons';
|
||||
import axios from 'axios';
|
||||
import { useConfig } from '../../../tools/state';
|
||||
import { IModule } from '../modules';
|
||||
import OverseerrMediaDisplay from '../overseerr/OverseerrMediaDisplay';
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
hide: {
|
||||
@@ -29,11 +37,35 @@ export const SearchModule: IModule = {
|
||||
export default function SearchBar(props: any) {
|
||||
const { config, setConfig } = useConfig();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [results, setOpenedResults] = useState(false);
|
||||
const [icon, setIcon] = useState(<Search />);
|
||||
const queryUrl = config.settings.searchUrl ?? 'https://www.google.com/search?q=';
|
||||
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 rightSection = (
|
||||
<div className={classes.hide}>
|
||||
@@ -43,12 +75,6 @@ export default function SearchBar(props: any) {
|
||||
</div>
|
||||
);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
query: '',
|
||||
},
|
||||
});
|
||||
|
||||
// If enabled modules doesn't contain the module, return null
|
||||
// If module in enabled
|
||||
|
||||
@@ -57,6 +83,7 @@ export default function SearchBar(props: any) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Data with label as item.name
|
||||
return (
|
||||
<form
|
||||
onChange={() => {
|
||||
@@ -89,36 +116,46 @@ export default function SearchBar(props: any) {
|
||||
})}
|
||||
>
|
||||
<Popover
|
||||
opened={opened}
|
||||
position="bottom"
|
||||
placement="start"
|
||||
width={260}
|
||||
withArrow
|
||||
radius="md"
|
||||
trapFocus={false}
|
||||
transition="pop-bottom-right"
|
||||
onFocusCapture={() => setOpened(true)}
|
||||
onBlurCapture={() => setOpened(false)}
|
||||
opened={results}
|
||||
target={
|
||||
<TextInput
|
||||
variant="filled"
|
||||
icon={icon}
|
||||
ref={textInput}
|
||||
rightSectionWidth={90}
|
||||
rightSection={rightSection}
|
||||
<Popover
|
||||
opened={opened}
|
||||
position="bottom"
|
||||
placement="start"
|
||||
width={260}
|
||||
withArrow
|
||||
radius="md"
|
||||
size="md"
|
||||
styles={{ rightSection: { pointerEvents: 'none' } }}
|
||||
placeholder="Search the web..."
|
||||
{...props}
|
||||
{...form.getInputProps('query')}
|
||||
/>
|
||||
trapFocus={false}
|
||||
transition="pop-bottom-right"
|
||||
onFocusCapture={() => setOpened(true)}
|
||||
onBlurCapture={() => setOpened(false)}
|
||||
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>
|
||||
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>
|
||||
{/* Loop on the first 5 items of data */}
|
||||
{data.slice(0, 5).map((item, index) => (
|
||||
<OverseerrMediaDisplay key={index} media={item} />
|
||||
))}
|
||||
</Popover>
|
||||
</form>
|
||||
);
|
||||
|
||||
41
src/pages/api/modules/overseerr.ts
Normal file
41
src/pages/api/modules/overseerr.ts
Normal 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',
|
||||
});
|
||||
};
|
||||
@@ -3,11 +3,14 @@ import OverseerrMediaDisplay, {
|
||||
OverseerrMedia,
|
||||
} from '../components/modules/overseerr/OverseerrMediaDisplay';
|
||||
import media from '../components/modules/overseerr/example.json';
|
||||
import { ModuleWrapper } from '../components/modules/moduleWrapper';
|
||||
import { SearchModule } from '../components/modules';
|
||||
|
||||
export default function TryOverseerr() {
|
||||
return (
|
||||
<Group direction="column">
|
||||
<OverseerrMediaDisplay media={media} />
|
||||
<ModuleWrapper module={SearchModule} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ServiceTypeList = [
|
||||
'Readarr',
|
||||
'Sonarr',
|
||||
'qBittorrent',
|
||||
'Overseerr',
|
||||
];
|
||||
export type ServiceType =
|
||||
| 'Other'
|
||||
@@ -41,7 +42,8 @@ export type ServiceType =
|
||||
| 'Radarr'
|
||||
| 'Readarr'
|
||||
| 'Sonarr'
|
||||
| 'qBittorrent';
|
||||
| 'qBittorrent'
|
||||
| 'Overseerr';
|
||||
|
||||
export interface serviceItem {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user