From c1463b3aa68754fa54ff944e9f108200c52c42cd Mon Sep 17 00:00:00 2001 From: Manuel Date: Tue, 4 Apr 2023 22:32:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20Add=20overseerr=20widget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../en/modules/media-requests-list.json | 9 ++ .../en/modules/media-requests-stats.json | 9 ++ src/pages/api/modules/media-requests/index.ts | 151 ++++++++++++++++++ src/tools/server/translation-namespaces.ts | 2 + src/widgets/index.ts | 4 + src/widgets/loading.tsx | 7 + .../media-requests/MediaRequestListTile.tsx | 123 ++++++++++++++ .../media-requests/MediaRequestStatsTile.tsx | 73 +++++++++ .../media-requests/media-request-query.tsx | 10 ++ .../media-requests/media-request-types.tsx | 22 +++ 10 files changed, 410 insertions(+) create mode 100644 public/locales/en/modules/media-requests-list.json create mode 100644 public/locales/en/modules/media-requests-stats.json create mode 100644 src/pages/api/modules/media-requests/index.ts create mode 100644 src/widgets/loading.tsx create mode 100644 src/widgets/media-requests/MediaRequestListTile.tsx create mode 100644 src/widgets/media-requests/MediaRequestStatsTile.tsx create mode 100644 src/widgets/media-requests/media-request-query.tsx create mode 100644 src/widgets/media-requests/media-request-types.tsx 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..948d28730 --- /dev/null +++ b/public/locales/en/modules/media-requests-list.json @@ -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" + } + } +} 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..4fbadfeb2 --- /dev/null +++ b/public/locales/en/modules/media-requests-stats.json @@ -0,0 +1,9 @@ +{ + "descriptor": { + "name": "Media request stats", + "description": "Statistics about your media requests", + "settings": { + "title": "Media requests stats" + } + } +} 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..c5d3a33d9 --- /dev/null +++ b/src/pages/api/modules/media-requests/index.ts @@ -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 => { + 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, + 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 => { + 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..de2ad4f52 --- /dev/null +++ b/src/widgets/media-requests/MediaRequestListTile.tsx @@ -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 ; + } + + if (data.length === 0) { + return ( +
+ There are no requests. Ensure that you've configured your apps correctly. +
+ ); + } + + const countPendingApproval = data.filter( + (x) => x.status === MediaRequestStatus.PendingApproval + ).length; + + return ( + + {countPendingApproval > 0 ? ( + There are {countPendingApproval} requests waiting for an approval. + ) : ( + There are currently no pending approvals. You're good to go! + )} + {data.map((item) => ( + + + + poster + + + {item.airDate.split('-')[0]} + + + + {item.name} + + + + + requester avatar + + {item.userName} + + + + + + + ))} + + ); +} + +const MediaRequestStatusBadge = ({ status }: { status: MediaRequestStatus }) => { + switch (status) { + case MediaRequestStatus.Approved: + return Approved; + case MediaRequestStatus.Declined: + return Declined; + case MediaRequestStatus.PendingApproval: + return Pending approval; + 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..2b82f9bf0 --- /dev/null +++ b/src/widgets/media-requests/MediaRequestStatsTile.tsx @@ -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 ; + } + + return ( + + +
+ + + {data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length} + + + Pending approvals + + +
+
+ +
+ + {data.filter((x) => x.type === 'tv').length} + + TV requests + + +
+
+ +
+ + {data.filter((x) => x.type === 'movie').length} + + Movie requests + + +
+
+
+ ); +} + +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..03a5874d1 --- /dev/null +++ b/src/widgets/media-requests/media-request-query.tsx @@ -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[]; + }, +}); 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 +} From 6b3fe8951a9169df80e81fb74a0c0a7941fae972 Mon Sep 17 00:00:00 2001 From: Manuel Date: Fri, 7 Apr 2023 20:06:27 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=9A=A8=20Fix=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Dashboard/Views/DashboardView.tsx | 4 ++-- src/pages/api/modules/media-requests/index.ts | 13 +++++++++---- src/widgets/media-requests/MediaRequestListTile.tsx | 9 ++++++++- .../media-requests/MediaRequestStatsTile.tsx | 2 +- src/widgets/media-requests/media-request-query.tsx | 1 + 5 files changed, 21 insertions(+), 8 deletions(-) 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 index fc141d6a7..0edf6e342 100644 --- a/src/pages/api/modules/media-requests/index.ts +++ b/src/pages/api/modules/media-requests/index.ts @@ -14,6 +14,8 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { ['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 }; @@ -39,8 +41,8 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { type: item.type, name: genericItem.name, userName: item.requestedBy.displayName, - userLink: constructAvatarUrl(app, item), - userProfilePicture: `${app.url}${item.requestedBy.avatar}`, + 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}`, @@ -64,11 +66,14 @@ const Get = async (request: NextApiRequest, response: NextApiResponse) => { }; const constructAvatarUrl = (app: ConfigAppType, item: OverseerrResponseItem) => { - if (item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://')) { + const isAbsolute = + item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://'); + + if (isAbsolute) { return item.requestedBy.avatar; } - return `${app.url}/users/${item.requestedBy.id}`; + return `${app.url}/${item.requestedBy.avatar}`; }; const retrieveDetailsForItem = async ( diff --git a/src/widgets/media-requests/MediaRequestListTile.tsx b/src/widgets/media-requests/MediaRequestListTile.tsx index 6c8a7278d..4cbdd47be 100644 --- a/src/widgets/media-requests/MediaRequestListTile.tsx +++ b/src/widgets/media-requests/MediaRequestListTile.tsx @@ -82,7 +82,14 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) { - requester avatar + requester avatar useQuery({ const response = await fetch('/api/modules/media-requests'); return (await response.json()) as MediaRequest[]; }, + refetchInterval: 3 * 60 * 1000, }); From 3850bc2dbe0b65f4ebbb2820686e29bc40d5c04e Mon Sep 17 00:00:00 2001 From: Thomas Camlong <49837342+ajnart@users.noreply.github.com> Date: Mon, 10 Apr 2023 23:13:01 +0900 Subject: [PATCH 3/3] Apply suggestions from code review --- public/locales/en/modules/media-requests-list.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/locales/en/modules/media-requests-list.json b/public/locales/en/modules/media-requests-list.json index 4e578586b..bf56d4e06 100644 --- a/public/locales/en/modules/media-requests-list.json +++ b/public/locales/en/modules/media-requests-list.json @@ -1,12 +1,12 @@ { "descriptor": { "name": "Media Requests", - "description": "See a list of all media requests from your Overseerr and Jellyseerr", + "description": "See a list of all media requests from your Overseerr or Jellyseerr instance", "settings": { "title": "Media requests list" } }, - "noRequests": "There are no requests. Ensure that you've configured your apps correctly.", + "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": {