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,
|
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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user