Add overseerr widget

This commit is contained in:
Manuel
2023-04-04 22:32:08 +02:00
parent 7cf6fe53fc
commit c1463b3aa6
10 changed files with 410 additions and 0 deletions

View File

@@ -0,0 +1,9 @@
{
"descriptor": {
"name": "Media Requests",
"description": "See a list of all media requests from your Overseerr and Jellyseerr",
"settings": {
"title": "Media requests list"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"descriptor": {
"name": "Media request stats",
"description": "Statistics about your media requests",
"settings": {
"title": "Media requests stats"
}
}
}

View File

@@ -0,0 +1,151 @@
import { getCookie } from 'cookies-next';
import Consola from 'consola';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../../../tools/config/getConfig';
import { MediaRequest } from '../../../../widgets/media-requests/media-request-types';
const Get = async (request: NextApiRequest, response: NextApiResponse) => {
const configName = getCookie('config-name', { req: request });
const config = getConfig(configName?.toString() ?? 'default');
const apps = config.apps.filter((app) =>
['overseerr', 'jellyseerr'].includes(app.integration?.type ?? '')
);
const promises = apps.map((app): Promise<MediaRequest[]> => {
const apiKey = app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
const headers: HeadersInit = { 'X-Api-Key': apiKey };
return fetch(`${app.url}/api/v1/request?take=25&skip=0&sort=added`, {
headers,
})
.then(async (response) => {
const body = (await response.json()) as OverseerrResponse;
const requests = await Promise.all(
body.results.map(async (item): Promise<MediaRequest> => {
const genericItem = await retrieveDetailsForItem(
app.url,
item.type,
headers,
item.media.tmdbId
);
return {
appId: app.id,
createdAt: item.createdAt,
id: item.id,
rootFolder: item.rootFolder,
type: item.type,
name: genericItem.name,
userName: item.requestedBy.displayName,
userLink: `${app.url}/users/${item.requestedBy.id}`,
userProfilePicture: `${app.url}${item.requestedBy.avatar}`,
airDate: genericItem.airDate,
status: item.status,
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
posterPath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${genericItem.posterPath}`,
href: `${app.url}/movie/${item.media.tmdbId}`,
};
})
);
return Promise.resolve(requests);
})
.catch((err) => {
Consola.error(`Failed to request data from Overseerr: ${err}`);
return Promise.resolve([]);
});
});
const mediaRequests = (await Promise.all(promises)).reduce((prev, cur) => prev.concat(cur));
return response.status(200).json(mediaRequests);
};
const retrieveDetailsForItem = async (
baseUrl: string,
type: OverseerrResponseItem['type'],
headers: HeadersInit,
id: number
): Promise<GenericOverseerrItem> => {
if (type === 'tv') {
const tvResponse = await fetch(`${baseUrl}/api/v1/tv/${id}`, {
headers,
});
const series = (await tvResponse.json()) as OverseerrSeries;
return {
name: series.name,
airDate: series.firstAirDate,
backdropPath: series.backdropPath,
posterPath: series.backdropPath,
};
}
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
headers,
});
const movie = (await movieResponse.json()) as OverseerrMovie;
return {
name: movie.originalTitle,
airDate: movie.releaseDate,
backdropPath: movie.backdropPath,
posterPath: movie.posterPath,
};
};
type GenericOverseerrItem = {
name: string;
airDate: string;
backdropPath: string;
posterPath: string;
};
type OverseerrMovie = {
originalTitle: string;
releaseDate: string;
backdropPath: string;
posterPath: string;
};
type OverseerrSeries = {
name: string;
firstAirDate: string;
backdropPath: string;
posterPath: string;
};
type OverseerrResponse = {
results: OverseerrResponseItem[];
};
type OverseerrResponseItem = {
id: number;
status: number;
createdAt: string;
type: 'movie' | 'tv';
rootFolder: string;
requestedBy: OverseerrResponseItemUser;
media: OverseerrResponseItemMedia;
};
type OverseerrResponseItemMedia = {
tmdbId: number;
};
type OverseerrResponseItemUser = {
id: number;
displayName: string;
avatar: string;
};
export default async (request: NextApiRequest, response: NextApiResponse) => {
if (request.method === 'GET') {
return Get(request, response);
}
return response.status(405);
};

View File

@@ -36,6 +36,8 @@ export const dashboardNamespaces = [
'modules/media-server',
'modules/common-media-cards',
'modules/video-stream',
'modules/media-requests-list',
'modules/media-requests-stats',
'widgets/error-boundary',
];

View File

@@ -9,6 +9,8 @@ import torrent from './torrent/TorrentTile';
import usenet from './useNet/UseNetTile';
import videoStream from './video/VideoStreamTile';
import weather from './weather/WeatherTile';
import mediaRequestsList from './media-requests/MediaRequestListTile';
import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
export default {
calendar,
@@ -22,4 +24,6 @@ export default {
'video-stream': videoStream,
iframe,
'media-server': mediaServer,
'media-requests-list': mediaRequestsList,
'media-requests-stats': mediaRequestsStats,
};

7
src/widgets/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { Center, Loader } from '@mantine/core';
export const WidgetLoading = () => (
<Center h="100%">
<Loader variant="bars" />
</Center>
);

View File

@@ -0,0 +1,123 @@
import { Badge, Card, Center, Flex, Group, Image, Stack, Text } from '@mantine/core';
import { IconGitPullRequest } from '@tabler/icons';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useMediaRequestQuery } from './media-request-query';
import { MediaRequestStatus } from './media-request-types';
const definition = defineWidget({
id: 'media-requests-list',
icon: IconGitPullRequest,
options: {},
component: MediaRequestListTile,
gridstack: {
minWidth: 3,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
});
export type MediaRequestListWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface MediaRequestListWidgetProps {
widget: MediaRequestListWidget;
}
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
const { data, isFetching } = useMediaRequestQuery();
if (!data || isFetching) {
return <WidgetLoading />;
}
if (data.length === 0) {
return (
<Center h="100%">
<Text>There are no requests. Ensure that you&apos;ve configured your apps correctly.</Text>
</Center>
);
}
const countPendingApproval = data.filter(
(x) => x.status === MediaRequestStatus.PendingApproval
).length;
return (
<Stack>
{countPendingApproval > 0 ? (
<Text>There are {countPendingApproval} requests waiting for an approval.</Text>
) : (
<Text>There are currently no pending approvals. You&apos;re good to go!</Text>
)}
{data.map((item) => (
<Card pos="relative" withBorder>
<Flex justify="space-between" gap="md">
<Flex gap="md">
<Image
src={item.posterPath}
width={30}
height={50}
alt="poster"
radius="xs"
withPlaceholder
/>
<Stack spacing={0}>
<Group spacing="xs">
<Text>{item.airDate.split('-')[0]}</Text>
<MediaRequestStatusBadge status={item.status} />
</Group>
<Text
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
lineClamp={1}
weight="bold"
component="a"
href={item.href}
>
{item.name}
</Text>
</Stack>
</Flex>
<Flex gap="xs">
<Image src={item.userProfilePicture} width={25} height={25} alt="requester avatar" />
<Text
component="a"
href={item.userLink}
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
>
{item.userName}
</Text>
</Flex>
</Flex>
<Image
src={item.backdropPath}
pos="absolute"
w="100%"
h="100%"
opacity={0.1}
top={0}
left={0}
style={{ pointerEvents: 'none' }}
/>
</Card>
))}
</Stack>
);
}
const MediaRequestStatusBadge = ({ status }: { status: MediaRequestStatus }) => {
switch (status) {
case MediaRequestStatus.Approved:
return <Badge color="green">Approved</Badge>;
case MediaRequestStatus.Declined:
return <Badge color="red">Declined</Badge>;
case MediaRequestStatus.PendingApproval:
return <Badge color="orange">Pending approval</Badge>;
default:
return <></>;
}
};
export default definition;

View File

@@ -0,0 +1,73 @@
import { Card, Center, Flex, Stack, Text } from '@mantine/core';
import { IconChartBar } from '@tabler/icons';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import { useMediaRequestQuery } from './media-request-query';
import { MediaRequestStatus } from './media-request-types';
const definition = defineWidget({
id: 'media-requests-stats',
icon: IconChartBar,
options: {},
component: MediaRequestStatsTile,
gridstack: {
minWidth: 1,
minHeight: 2,
maxWidth: 12,
maxHeight: 12,
},
});
export type MediaRequestStatsWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface MediaRequestStatsWidgetProps {
widget: MediaRequestStatsWidget;
}
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
const { data, isFetching } = useMediaRequestQuery();
if (!data || isFetching) {
return <WidgetLoading />;
}
return (
<Flex gap="md" wrap="wrap">
<Card w={100} h={100} withBorder>
<Center h="100%">
<Stack spacing={0} align="center">
<Text>
{data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length}
</Text>
<Text color="dimmed" align="center" size="xs">
Pending approvals
</Text>
</Stack>
</Center>
</Card>
<Card w={100} h={100} withBorder>
<Center h="100%">
<Stack spacing={0} align="center">
<Text align="center">{data.filter((x) => x.type === 'tv').length}</Text>
<Text color="dimmed" align="center" size="xs">
TV requests
</Text>
</Stack>
</Center>
</Card>
<Card w={100} h={100} withBorder>
<Center h="100%">
<Stack spacing={0} align="center">
<Text align="center">{data.filter((x) => x.type === 'movie').length}</Text>
<Text color="dimmed" align="center" size="xs">
Movie requests
</Text>
</Stack>
</Center>
</Card>
</Flex>
);
}
export default definition;

View File

@@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { MediaRequest } from './media-request-types';
export const useMediaRequestQuery = () => useQuery({
queryKey: ['media-requests'],
queryFn: async () => {
const response = await fetch('/api/modules/media-requests');
return (await response.json()) as MediaRequest[];
},
});

View File

@@ -0,0 +1,22 @@
export type MediaRequest = {
appId: string;
id: number;
createdAt: string;
rootFolder: string;
type: 'movie' | 'tv';
name: string;
userName: string;
userProfilePicture: string;
userLink: string;
airDate: string;
status: MediaRequestStatus;
backdropPath: string;
posterPath: string;
href: string;
};
export enum MediaRequestStatus {
PendingApproval = 1,
Approved = 2,
Declined = 3
}