mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-09 16:17:07 +01:00
Merge pull request #796 from ajnart/feat/overseerr-widget
✨ Add overseerr widget
This commit is contained in:
17
public/locales/en/modules/media-requests-list.json
Normal file
17
public/locales/en/modules/media-requests-list.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"descriptor": {
|
||||
"name": "Media Requests",
|
||||
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",
|
||||
"settings": {
|
||||
"title": "Media requests list"
|
||||
}
|
||||
},
|
||||
"noRequests": "No requests found. Please ensure that you've configured your apps correctly.",
|
||||
"pending": "There are {countPendingApproval} requests waiting for an approval.",
|
||||
"nonePending": "There are currently no pending approvals. You're good to go!",
|
||||
"state": {
|
||||
"approved": "Approved",
|
||||
"pendingApproval": "Pending approval",
|
||||
"declined": "Declined"
|
||||
}
|
||||
}
|
||||
14
public/locales/en/modules/media-requests-stats.json
Normal file
14
public/locales/en/modules/media-requests-stats.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"descriptor": {
|
||||
"name": "Media request stats",
|
||||
"description": "Statistics about your media requests",
|
||||
"settings": {
|
||||
"title": "Media requests stats"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"pending": "Pending approvals",
|
||||
"tvRequests": "TV requests",
|
||||
"movieRequests": "Movie requests"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Center, Group, Loader, Stack } from '@mantine/core';
|
||||
import { Group, Stack } from '@mantine/core';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useConfigContext } from '../../../config/provider';
|
||||
import { useResize } from '../../../hooks/use-resize';
|
||||
@@ -6,9 +6,9 @@ import { useScreenLargerThan } from '../../../hooks/useScreenLargerThan';
|
||||
import { CategoryType } from '../../../types/category';
|
||||
import { WrapperType } from '../../../types/wrapper';
|
||||
import { DashboardCategory } from '../Wrappers/Category/Category';
|
||||
import { useGridstackStore } from '../Wrappers/gridstack/store';
|
||||
import { DashboardSidebar } from '../Wrappers/Sidebar/Sidebar';
|
||||
import { DashboardWrapper } from '../Wrappers/Wrapper/Wrapper';
|
||||
import { useGridstackStore } from '../Wrappers/gridstack/store';
|
||||
|
||||
export const DashboardView = () => {
|
||||
const wrappers = useWrapperItems();
|
||||
|
||||
165
src/pages/api/modules/media-requests/index.ts
Normal file
165
src/pages/api/modules/media-requests/index.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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';
|
||||
import { ConfigAppType } from '../../../../types/app';
|
||||
|
||||
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 ?? '')
|
||||
);
|
||||
|
||||
Consola.log(`Retrieving media requests from ${apps.length} apps`);
|
||||
|
||||
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,
|
||||
userProfilePicture: constructAvatarUrl(app, item),
|
||||
userLink: `${app.url}/users/${item.requestedBy.id}`,
|
||||
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 constructAvatarUrl = (app: ConfigAppType, item: OverseerrResponseItem) => {
|
||||
const isAbsolute =
|
||||
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
|
||||
|
||||
if (isAbsolute) {
|
||||
return item.requestedBy.avatar;
|
||||
}
|
||||
|
||||
return `${app.url}/${item.requestedBy.avatar}`;
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
133
src/widgets/media-requests/MediaRequestListTile.tsx
Normal file
133
src/widgets/media-requests/MediaRequestListTile.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Badge, Card, Center, Flex, Group, Image, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
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 { t } = useTranslation('modules/media-requests-list');
|
||||
const { data, isFetching } = useMediaRequestQuery();
|
||||
|
||||
if (!data || isFetching) {
|
||||
return <WidgetLoading />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{t('noRequests')}</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const countPendingApproval = data.filter(
|
||||
(x) => x.status === MediaRequestStatus.PendingApproval
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{countPendingApproval > 0 ? (
|
||||
<Text>{t('pending', { countPendingApproval })}</Text>
|
||||
) : (
|
||||
<Text>{t('nonePending')}</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"
|
||||
radius="xl"
|
||||
withPlaceholder
|
||||
/>
|
||||
<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 }) => {
|
||||
const { t } = useTranslation('modules/media-requests-list');
|
||||
switch (status) {
|
||||
case MediaRequestStatus.Approved:
|
||||
return <Badge color="green">{t('state.approved')}</Badge>;
|
||||
case MediaRequestStatus.Declined:
|
||||
return <Badge color="red">{t('state.declined')}</Badge>;
|
||||
case MediaRequestStatus.PendingApproval:
|
||||
return <Badge color="orange">{t('state.pendingApproval')}</Badge>;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export default definition;
|
||||
75
src/widgets/media-requests/MediaRequestStatsTile.tsx
Normal file
75
src/widgets/media-requests/MediaRequestStatsTile.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Card, Center, Flex, Stack, Text } from '@mantine/core';
|
||||
import { IconChartBar } from '@tabler/icons';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
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 { t } = useTranslation('modules/media-requests-stats');
|
||||
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">
|
||||
{t('stats.pending')}
|
||||
</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">
|
||||
{t('stats.tvRequests')}
|
||||
</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">
|
||||
{t('stats.movieRequests')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default definition;
|
||||
11
src/widgets/media-requests/media-request-query.tsx
Normal file
11
src/widgets/media-requests/media-request-query.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
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[];
|
||||
},
|
||||
refetchInterval: 3 * 60 * 1000,
|
||||
});
|
||||
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