mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-10 23:45:48 +01:00
✨ Lidarr and Readarr integrations
This commit is contained in:
@@ -173,7 +173,10 @@ export function AddAppShelfItemForm(props: { setOpened: (b: boolean) => void } &
|
|||||||
{...form.getInputProps('type')}
|
{...form.getInputProps('type')}
|
||||||
/>
|
/>
|
||||||
<LoadingOverlay visible={isLoading} />
|
<LoadingOverlay visible={isLoading} />
|
||||||
{(form.values.type === 'Sonarr' || form.values.type === 'Radarr') && (
|
{(form.values.type === 'Sonarr' ||
|
||||||
|
form.values.type === 'Radarr' ||
|
||||||
|
form.values.type === 'Lidarr' ||
|
||||||
|
form.values.type === 'Readarr') && (
|
||||||
<TextInput
|
<TextInput
|
||||||
required
|
required
|
||||||
label="API key"
|
label="API key"
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
/* eslint-disable react/no-children-prop */
|
/* eslint-disable react/no-children-prop */
|
||||||
import { Popover, Box, ScrollArea, Divider, Indicator, useMantineTheme } from '@mantine/core';
|
import { Box, Divider, Indicator, Popover, ScrollArea, useMantineTheme } from '@mantine/core';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Calendar } from '@mantine/dates';
|
import { Calendar } from '@mantine/dates';
|
||||||
import { showNotification } from '@mantine/notifications';
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react';
|
import { Calendar as CalendarIcon, Check } from 'tabler-icons-react';
|
||||||
import { RadarrMediaDisplay, SonarrMediaDisplay } from './MediaDisplay';
|
|
||||||
import { useConfig } from '../../../tools/state';
|
import { useConfig } from '../../../tools/state';
|
||||||
import { IModule } from '../modules';
|
import { IModule } from '../modules';
|
||||||
|
import {
|
||||||
|
SonarrMediaDisplay,
|
||||||
|
RadarrMediaDisplay,
|
||||||
|
LidarrMediaDisplay,
|
||||||
|
ReadarrMediaDisplay,
|
||||||
|
} from './MediaDisplay';
|
||||||
|
|
||||||
export const CalendarModule: IModule = {
|
export const CalendarModule: IModule = {
|
||||||
title: 'Calendar',
|
title: 'Calendar',
|
||||||
@@ -19,17 +24,25 @@ export const CalendarModule: IModule = {
|
|||||||
export default function CalendarComponent(props: any) {
|
export default function CalendarComponent(props: any) {
|
||||||
const { config } = useConfig();
|
const { config } = useConfig();
|
||||||
const [sonarrMedias, setSonarrMedias] = useState([] as any);
|
const [sonarrMedias, setSonarrMedias] = useState([] as any);
|
||||||
|
const [lidarrMedias, setLidarrMedias] = useState([] as any);
|
||||||
const [radarrMedias, setRadarrMedias] = useState([] as any);
|
const [radarrMedias, setRadarrMedias] = useState([] as any);
|
||||||
|
const [readarrMedias, setReadarrMedias] = useState([] as any);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Filter only sonarr and radarr services
|
// Filter only sonarr and radarr services
|
||||||
const filtered = config.services.filter(
|
const filtered = config.services.filter(
|
||||||
(service) => service.type === 'Sonarr' || service.type === 'Radarr'
|
(service) =>
|
||||||
|
service.type === 'Sonarr' ||
|
||||||
|
service.type === 'Radarr' ||
|
||||||
|
service.type === 'Lidarr' ||
|
||||||
|
service.type === 'Readarr'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the url and apiKey for all Sonarr and Radarr services
|
// Get the url and apiKey for all Sonarr and Radarr services
|
||||||
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0);
|
const sonarrService = filtered.filter((service) => service.type === 'Sonarr').at(0);
|
||||||
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
|
const radarrService = filtered.filter((service) => service.type === 'Radarr').at(0);
|
||||||
|
const lidarrService = filtered.filter((service) => service.type === 'Lidarr').at(0);
|
||||||
|
const readarrService = filtered.filter((service) => service.type === 'Readarr').at(0);
|
||||||
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
|
const nextMonth = new Date(new Date().setMonth(new Date().getMonth() + 2)).toISOString();
|
||||||
if (sonarrService && sonarrService.apiKey) {
|
if (sonarrService && sonarrService.apiKey) {
|
||||||
const baseUrl = new URL(sonarrService.url).origin;
|
const baseUrl = new URL(sonarrService.url).origin;
|
||||||
@@ -69,6 +82,44 @@ export default function CalendarComponent(props: any) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (lidarrService && lidarrService.apiKey) {
|
||||||
|
const baseUrl = new URL(lidarrService.url).origin;
|
||||||
|
fetch(`${baseUrl}/api/v1/calendar?apikey=${lidarrService?.apiKey}&end=${nextMonth}`).then(
|
||||||
|
(response) => {
|
||||||
|
response.ok &&
|
||||||
|
response.json().then((data) => {
|
||||||
|
setLidarrMedias(data);
|
||||||
|
showNotification({
|
||||||
|
title: 'Lidarr',
|
||||||
|
icon: <Check />,
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 1500,
|
||||||
|
radius: 'md',
|
||||||
|
message: `Loaded ${data.length} releases`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (readarrService && readarrService.apiKey) {
|
||||||
|
const baseUrl = new URL(readarrService.url).origin;
|
||||||
|
fetch(`${baseUrl}/api/v1/calendar?apikey=${readarrService?.apiKey}&end=${nextMonth}`).then(
|
||||||
|
(response) => {
|
||||||
|
response.ok &&
|
||||||
|
response.json().then((data) => {
|
||||||
|
setReadarrMedias(data);
|
||||||
|
showNotification({
|
||||||
|
title: 'Readarr',
|
||||||
|
icon: <Check />,
|
||||||
|
color: 'green',
|
||||||
|
autoClose: 1500,
|
||||||
|
radius: 'md',
|
||||||
|
message: `Loaded ${data.length} releases`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}, [config.services]);
|
}, [config.services]);
|
||||||
|
|
||||||
if (sonarrMedias === undefined && radarrMedias === undefined) {
|
if (sonarrMedias === undefined && radarrMedias === undefined) {
|
||||||
@@ -82,6 +133,8 @@ export default function CalendarComponent(props: any) {
|
|||||||
renderdate={renderdate}
|
renderdate={renderdate}
|
||||||
sonarrmedias={sonarrMedias}
|
sonarrmedias={sonarrMedias}
|
||||||
radarrmedias={radarrMedias}
|
radarrmedias={radarrMedias}
|
||||||
|
lidarrmedias={lidarrMedias}
|
||||||
|
readarrmedias={readarrMedias}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -93,12 +146,25 @@ function DayComponent(props: any) {
|
|||||||
renderdate,
|
renderdate,
|
||||||
sonarrmedias,
|
sonarrmedias,
|
||||||
radarrmedias,
|
radarrmedias,
|
||||||
}: { renderdate: Date; sonarrmedias: []; radarrmedias: [] } = props;
|
lidarrmedias,
|
||||||
|
readarrmedias,
|
||||||
|
}: { renderdate: Date; sonarrmedias: []; radarrmedias: []; lidarrmedias: []; readarrmedias: [] } =
|
||||||
|
props;
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
const day = renderdate.getDate();
|
const day = renderdate.getDate();
|
||||||
// Itterate over the medias and filter the ones that are on the same day
|
|
||||||
|
const readarrFiltered = readarrmedias.filter((media: any) => {
|
||||||
|
const mediaDate = new Date(media.releaseDate);
|
||||||
|
return mediaDate.getDate() === day;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lidarrFiltered = lidarrmedias.filter((media: any) => {
|
||||||
|
const date = new Date(media.releaseDate);
|
||||||
|
// Return true if the date is renerdate without counting hours and minutes
|
||||||
|
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||||
|
});
|
||||||
const sonarrFiltered = sonarrmedias.filter((media: any) => {
|
const sonarrFiltered = sonarrmedias.filter((media: any) => {
|
||||||
const date = new Date(media.airDate);
|
const date = new Date(media.airDate);
|
||||||
// Return true if the date is renerdate without counting hours and minutes
|
// Return true if the date is renerdate without counting hours and minutes
|
||||||
@@ -109,7 +175,12 @@ function DayComponent(props: any) {
|
|||||||
// Return true if the date is renerdate without counting hours and minutes
|
// Return true if the date is renerdate without counting hours and minutes
|
||||||
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
return date.getDate() === day && date.getMonth() === renderdate.getMonth();
|
||||||
});
|
});
|
||||||
if (sonarrFiltered.length === 0 && radarrFiltered.length === 0) {
|
if (
|
||||||
|
sonarrFiltered.length === 0 &&
|
||||||
|
radarrFiltered.length === 0 &&
|
||||||
|
lidarrFiltered.length === 0 &&
|
||||||
|
readarrFiltered.length === 0
|
||||||
|
) {
|
||||||
return <div>{day}</div>;
|
return <div>{day}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +190,58 @@ function DayComponent(props: any) {
|
|||||||
setOpened(true);
|
setOpened(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{radarrFiltered.length > 0 && <Indicator size={7} color="yellow" children={null} />}
|
{readarrFiltered.length > 0 && (
|
||||||
{sonarrFiltered.length > 0 && <Indicator size={7} offset={8} color="blue" children={null} />}
|
<Indicator
|
||||||
|
size={10}
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
left: 8,
|
||||||
|
}}
|
||||||
|
color="red"
|
||||||
|
children={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{radarrFiltered.length > 0 && (
|
||||||
|
<Indicator
|
||||||
|
size={10}
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
left: 8,
|
||||||
|
}}
|
||||||
|
color="yellow"
|
||||||
|
children={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sonarrFiltered.length > 0 && (
|
||||||
|
<Indicator
|
||||||
|
size={10}
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
color="blue"
|
||||||
|
children={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{lidarrFiltered.length > 0 && (
|
||||||
|
<Indicator
|
||||||
|
size={10}
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
}}
|
||||||
|
color="green"
|
||||||
|
children={undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Popover
|
<Popover
|
||||||
position="left"
|
position="left"
|
||||||
radius="lg"
|
radius="lg"
|
||||||
@@ -147,6 +268,18 @@ function DayComponent(props: any) {
|
|||||||
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
{index < radarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
|
{lidarrFiltered.map((media: any, index: number) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<LidarrMediaDisplay media={media} />
|
||||||
|
{index < lidarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{readarrFiltered.map((media: any, index: number) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<ReadarrMediaDisplay media={media} />
|
||||||
|
{index < readarrFiltered.length - 1 && <Divider variant="dashed" my="xl" />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Popover>
|
</Popover>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Link } from 'tabler-icons-react';
|
|||||||
|
|
||||||
export interface IMedia {
|
export interface IMedia {
|
||||||
overview: string;
|
overview: string;
|
||||||
imdbId: any;
|
imdbId?: any;
|
||||||
|
artist?: string;
|
||||||
title: string;
|
title: string;
|
||||||
poster: string;
|
poster?: string;
|
||||||
genres: string[];
|
genres: string[];
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
episodeNumber?: number;
|
episodeNumber?: number;
|
||||||
@@ -15,14 +16,17 @@ function MediaDisplay(props: { media: IMedia }) {
|
|||||||
const { media }: { media: IMedia } = props;
|
const { media }: { media: IMedia } = props;
|
||||||
return (
|
return (
|
||||||
<Group noWrap align="self-start" mr={15}>
|
<Group noWrap align="self-start" mr={15}>
|
||||||
<Image
|
{media.poster && (
|
||||||
radius="md"
|
<Image
|
||||||
fit="cover"
|
radius="md"
|
||||||
src={media.poster}
|
fit="cover"
|
||||||
alt={media.title}
|
src={media.poster}
|
||||||
width={300}
|
alt={media.title}
|
||||||
height={400}
|
width={300}
|
||||||
/>
|
height={400}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
sx={(theme) => ({
|
sx={(theme) => ({
|
||||||
@@ -32,12 +36,28 @@ function MediaDisplay(props: { media: IMedia }) {
|
|||||||
<Group direction="column">
|
<Group direction="column">
|
||||||
<Group noWrap>
|
<Group noWrap>
|
||||||
<Title order={3}>{media.title}</Title>
|
<Title order={3}>{media.title}</Title>
|
||||||
<Anchor href={`https://www.imdb.com/title/${media.imdbId}`} target="_blank">
|
{media.imdbId && (
|
||||||
<ActionIcon>
|
<Anchor
|
||||||
<Link />
|
href={`https://www.imdb.com/title/${media.imdbId}`}
|
||||||
</ActionIcon>
|
target="_blank"
|
||||||
</Anchor>
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<ActionIcon>
|
||||||
|
<Link />
|
||||||
|
</ActionIcon>
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
{media.artist && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#a0aec0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New album from {media.artist}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
{media.episodeNumber && media.seasonNumber && (
|
{media.episodeNumber && media.seasonNumber && (
|
||||||
<Text
|
<Text
|
||||||
style={{
|
style={{
|
||||||
@@ -63,6 +83,42 @@ function MediaDisplay(props: { media: IMedia }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReadarrMediaDisplay(props: any) {
|
||||||
|
const { media }: { media: any } = props;
|
||||||
|
// Find a poster CoverType
|
||||||
|
const poster = media.author.images.find((image: any) => image.coverType === 'poster');
|
||||||
|
// Return a movie poster containting the title and the description
|
||||||
|
return (
|
||||||
|
<MediaDisplay
|
||||||
|
media={{
|
||||||
|
title: media.title,
|
||||||
|
poster: poster ? poster.url : undefined,
|
||||||
|
artist: media.author.authorName,
|
||||||
|
overview: media.overview,
|
||||||
|
genres: media.genres,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LidarrMediaDisplay(props: any) {
|
||||||
|
const { media }: { media: any } = props;
|
||||||
|
// Find a poster CoverType
|
||||||
|
const poster = media.artist.images.find((image: any) => image.coverType === 'poster');
|
||||||
|
// Return a movie poster containting the title and the description
|
||||||
|
return (
|
||||||
|
<MediaDisplay
|
||||||
|
media={{
|
||||||
|
title: media.title,
|
||||||
|
poster: poster ? poster.url : undefined,
|
||||||
|
artist: media.artist.artistName,
|
||||||
|
overview: media.overview,
|
||||||
|
genres: media.genres,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RadarrMediaDisplay(props: any) {
|
export function RadarrMediaDisplay(props: any) {
|
||||||
const { media }: { media: any } = props;
|
const { media }: { media: any } = props;
|
||||||
// Find a poster CoverType
|
// Find a poster CoverType
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const ServiceTypeList = [
|
|||||||
'Lidarr',
|
'Lidarr',
|
||||||
'Plex',
|
'Plex',
|
||||||
'Radarr',
|
'Radarr',
|
||||||
|
'Readarr',
|
||||||
'Sonarr',
|
'Sonarr',
|
||||||
'qBittorrent',
|
'qBittorrent',
|
||||||
];
|
];
|
||||||
@@ -36,6 +37,7 @@ export type ServiceType =
|
|||||||
| 'Lidarr'
|
| 'Lidarr'
|
||||||
| 'Plex'
|
| 'Plex'
|
||||||
| 'Radarr'
|
| 'Radarr'
|
||||||
|
| 'Readarr'
|
||||||
| 'Sonarr'
|
| 'Sonarr'
|
||||||
| 'qBittorrent';
|
| 'qBittorrent';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user