mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-11 07:55:52 +01:00
✨ Add overseerr widget
This commit is contained in:
9
public/locales/en/modules/media-requests-list.json
Normal file
9
public/locales/en/modules/media-requests-list.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
public/locales/en/modules/media-requests-stats.json
Normal file
9
public/locales/en/modules/media-requests-stats.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"descriptor": {
|
||||
"name": "Media request stats",
|
||||
"description": "Statistics about your media requests",
|
||||
"settings": {
|
||||
"title": "Media requests stats"
|
||||
}
|
||||
}
|
||||
}
|
||||
151
src/pages/api/modules/media-requests/index.ts
Normal file
151
src/pages/api/modules/media-requests/index.ts
Normal 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);
|
||||
};
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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
7
src/widgets/loading.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
|
||||
export const WidgetLoading = () => (
|
||||
<Center h="100%">
|
||||
<Loader variant="bars" />
|
||||
</Center>
|
||||
);
|
||||
123
src/widgets/media-requests/MediaRequestListTile.tsx
Normal file
123
src/widgets/media-requests/MediaRequestListTile.tsx
Normal 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'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'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;
|
||||
73
src/widgets/media-requests/MediaRequestStatsTile.tsx
Normal file
73
src/widgets/media-requests/MediaRequestStatsTile.tsx
Normal 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;
|
||||
10
src/widgets/media-requests/media-request-query.tsx
Normal file
10
src/widgets/media-requests/media-request-query.tsx
Normal 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[];
|
||||
},
|
||||
});
|
||||
22
src/widgets/media-requests/media-request-types.tsx
Normal file
22
src/widgets/media-requests/media-request-types.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user