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/media-server',
|
||||||
'modules/common-media-cards',
|
'modules/common-media-cards',
|
||||||
'modules/video-stream',
|
'modules/video-stream',
|
||||||
|
'modules/media-requests-list',
|
||||||
|
'modules/media-requests-stats',
|
||||||
'widgets/error-boundary',
|
'widgets/error-boundary',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import torrent from './torrent/TorrentTile';
|
|||||||
import usenet from './useNet/UseNetTile';
|
import usenet from './useNet/UseNetTile';
|
||||||
import videoStream from './video/VideoStreamTile';
|
import videoStream from './video/VideoStreamTile';
|
||||||
import weather from './weather/WeatherTile';
|
import weather from './weather/WeatherTile';
|
||||||
|
import mediaRequestsList from './media-requests/MediaRequestListTile';
|
||||||
|
import mediaRequestsStats from './media-requests/MediaRequestStatsTile';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
calendar,
|
calendar,
|
||||||
@@ -22,4 +24,6 @@ export default {
|
|||||||
'video-stream': videoStream,
|
'video-stream': videoStream,
|
||||||
iframe,
|
iframe,
|
||||||
'media-server': mediaServer,
|
'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