diff --git a/public/locales/en/modules/media-requests-list.json b/public/locales/en/modules/media-requests-list.json new file mode 100644 index 000000000..bf56d4e06 --- /dev/null +++ b/public/locales/en/modules/media-requests-list.json @@ -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" + } +} diff --git a/public/locales/en/modules/media-requests-stats.json b/public/locales/en/modules/media-requests-stats.json new file mode 100644 index 000000000..3c7d10090 --- /dev/null +++ b/public/locales/en/modules/media-requests-stats.json @@ -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" + } +} diff --git a/src/components/Dashboard/Views/DashboardView.tsx b/src/components/Dashboard/Views/DashboardView.tsx index b16c86385..c87c17050 100644 --- a/src/components/Dashboard/Views/DashboardView.tsx +++ b/src/components/Dashboard/Views/DashboardView.tsx @@ -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(); diff --git a/src/pages/api/modules/media-requests/index.ts b/src/pages/api/modules/media-requests/index.ts new file mode 100644 index 000000000..0edf6e342 --- /dev/null +++ b/src/pages/api/modules/media-requests/index.ts @@ -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 => { + 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 => { + 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 => { + 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); +}; diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 90aedada9..8a2d3ad13 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -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', ]; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 820795f24..4250bfd07 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -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, }; diff --git a/src/widgets/loading.tsx b/src/widgets/loading.tsx new file mode 100644 index 000000000..61386165e --- /dev/null +++ b/src/widgets/loading.tsx @@ -0,0 +1,7 @@ +import { Center, Loader } from '@mantine/core'; + +export const WidgetLoading = () => ( +
+ +
+ ); diff --git a/src/widgets/media-requests/MediaRequestListTile.tsx b/src/widgets/media-requests/MediaRequestListTile.tsx new file mode 100644 index 000000000..4cbdd47be --- /dev/null +++ b/src/widgets/media-requests/MediaRequestListTile.tsx @@ -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 ; + } + + if (data.length === 0) { + return ( +
+ {t('noRequests')} +
+ ); + } + + const countPendingApproval = data.filter( + (x) => x.status === MediaRequestStatus.PendingApproval + ).length; + + return ( + + {countPendingApproval > 0 ? ( + {t('pending', { countPendingApproval })} + ) : ( + {t('nonePending')} + )} + {data.map((item) => ( + + + + poster + + + {item.airDate.split('-')[0]} + + + + {item.name} + + + + + requester avatar + + {item.userName} + + + + + + + ))} + + ); +} + +const MediaRequestStatusBadge = ({ status }: { status: MediaRequestStatus }) => { + const { t } = useTranslation('modules/media-requests-list'); + switch (status) { + case MediaRequestStatus.Approved: + return {t('state.approved')}; + case MediaRequestStatus.Declined: + return {t('state.declined')}; + case MediaRequestStatus.PendingApproval: + return {t('state.pendingApproval')}; + default: + return <>; + } +}; + +export default definition; diff --git a/src/widgets/media-requests/MediaRequestStatsTile.tsx b/src/widgets/media-requests/MediaRequestStatsTile.tsx new file mode 100644 index 000000000..8dddac742 --- /dev/null +++ b/src/widgets/media-requests/MediaRequestStatsTile.tsx @@ -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 ; + } + + return ( + + +
+ + + {data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length} + + + {t('stats.pending')} + + +
+
+ +
+ + {data.filter((x) => x.type === 'tv').length} + + {t('stats.tvRequests')} + + +
+
+ +
+ + {data.filter((x) => x.type === 'movie').length} + + {t('stats.movieRequests')} + + +
+
+
+ ); +} + +export default definition; diff --git a/src/widgets/media-requests/media-request-query.tsx b/src/widgets/media-requests/media-request-query.tsx new file mode 100644 index 000000000..10ec3541f --- /dev/null +++ b/src/widgets/media-requests/media-request-query.tsx @@ -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, +}); diff --git a/src/widgets/media-requests/media-request-types.tsx b/src/widgets/media-requests/media-request-types.tsx new file mode 100644 index 000000000..7ad40007a --- /dev/null +++ b/src/widgets/media-requests/media-request-types.tsx @@ -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 +}