diff --git a/public/locales/en/modules/media-requests-list.json b/public/locales/en/modules/media-requests-list.json index 9016a0f0f..7f37e800d 100644 --- a/public/locales/en/modules/media-requests-list.json +++ b/public/locales/en/modules/media-requests-list.json @@ -6,6 +6,9 @@ "title": "Media requests list", "replaceLinksWithExternalHost": { "label": "Replace links with external host" + }, + "openInNewTab": { + "label": "Open links in new tab" } } }, diff --git a/public/locales/en/modules/media-requests-stats.json b/public/locales/en/modules/media-requests-stats.json index 3027c8e1c..96603a309 100644 --- a/public/locales/en/modules/media-requests-stats.json +++ b/public/locales/en/modules/media-requests-stats.json @@ -4,18 +4,24 @@ "description": "Statistics about your media requests", "settings": { "title": "Media requests stats", - "direction": { - "label": "Direction of the layout.", - "data":{ - "row": "Horizontal", - "column": "Vertical" - } + "replaceLinksWithExternalHost": { + "label": "Replace links with external host" + }, + "openInNewTab": { + "label": "Open links in new tab" } } }, - "stats": { + "mediaStats": { + "title": "Media Stats", "pending": "Pending approvals", "tvRequests": "TV requests", - "movieRequests": "Movie requests" + "movieRequests": "Movie requests", + "approved": "Already approved", + "totalRequests": "Total" + }, + "userStats": { + "title": "Top Users", + "requests": "Requests: {{number}}" } } diff --git a/src/server/api/routers/media-request.ts b/src/server/api/routers/media-request.ts index 99d91a5e0..a4e350cc3 100644 --- a/src/server/api/routers/media-request.ts +++ b/src/server/api/routers/media-request.ts @@ -3,15 +3,17 @@ import { z } from 'zod'; import { checkIntegrationsType } from '~/tools/client/app-properties'; import { getConfig } from '~/tools/config/getConfig'; import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile'; -import { MediaRequest } from '~/widgets/media-requests/media-request-types'; +import { MediaRequest, Users } from '~/widgets/media-requests/media-request-types'; import { createTRPCRouter, publicProcedure } from '../trpc'; +import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile'; export const mediaRequestsRouter = createTRPCRouter({ - all: publicProcedure + allMedia: publicProcedure .input( z.object({ configName: z.string(), + widget: z.custom().or(z.custom()), }) ) .query(async ({ input }) => { @@ -21,8 +23,6 @@ export const mediaRequestsRouter = createTRPCRouter({ checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr']) ); - 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 ?? ''; @@ -32,14 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .then(async (response) => { const body = (await response.json()) as OverseerrResponse; - const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as - | MediaRequestListWidget - | undefined; - if (!mediaWidget) { - Consola.log('No media-requests-list found'); - return Promise.resolve([]); - } - const appUrl = mediaWidget.properties.replaceLinksWithExternalHost + const appUrl = input.widget.properties.replaceLinksWithExternalHost ? app.behaviour.externalUrl : app.url; @@ -59,8 +52,9 @@ export const mediaRequestsRouter = createTRPCRouter({ type: item.type, name: genericItem.name, userName: item.requestedBy.displayName, - userProfilePicture: constructAvatarUrl(appUrl, item), + userProfilePicture: constructAvatarUrl(appUrl, item.requestedBy.avatar), userLink: `${appUrl}/users/${item.requestedBy.id}`, + userRequestCount: item.requestedBy.requestCount, airDate: genericItem.airDate, status: item.status, backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`, @@ -85,17 +79,66 @@ export const mediaRequestsRouter = createTRPCRouter({ return mediaRequests; }), + users: publicProcedure + .input( + z.object({ + configName: z.string(), + widget: z.custom().or(z.custom()), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + + const apps = config.apps.filter((app) => + checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr']) + ); + + 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/user?take=25&skip=0&sort=requests`, { + headers, + }) + .then(async (response) => { + const body = (await response.json()) as OverseerrUsers; + const appUrl = input.widget.properties.replaceLinksWithExternalHost + ? app.behaviour.externalUrl + : app.url; + + const users = await Promise.all( + body.results.map(async (user): Promise => { + return { + app: app.integration?.type ?? 'overseerr', + id: user.id, + userName: user.displayName, + userProfilePicture: constructAvatarUrl(appUrl, user.avatar), + userLink: `${appUrl}/users/${user.id}`, + userRequestCount: user.requestCount, + }; + }) + ); + return Promise.resolve(users); + }) + .catch((err) => { + Consola.error(`Failed to request users from Overseerr: ${err}`); + return Promise.resolve([]); + }); + }); + const users = (await Promise.all(promises)).reduce((prev, cur) => prev.concat(cur), []); + + return users; + }), }); -const constructAvatarUrl = (appUrl: string, item: OverseerrResponseItem) => { - const isAbsolute = - item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://'); +const constructAvatarUrl = (appUrl: string, avatar: string) => { + const isAbsolute = avatar.startsWith('http://') || avatar.startsWith('https://'); if (isAbsolute) { - return item.requestedBy.avatar; + return avatar; } - return `${appUrl}/${item.requestedBy.avatar}`; + return `${appUrl}/${avatar}`; }; const retrieveDetailsForItem = async ( @@ -117,7 +160,7 @@ const retrieveDetailsForItem = async ( backdropPath: series.backdropPath, posterPath: series.backdropPath, }; - } + }; const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, { headers, @@ -158,6 +201,10 @@ type OverseerrResponse = { results: OverseerrResponseItem[]; }; +type OverseerrUsers = { + results: OverseerrResponseItemUser[]; +}; + type OverseerrResponseItem = { id: number; status: number; @@ -176,4 +223,5 @@ type OverseerrResponseItemUser = { id: number; displayName: string; avatar: string; + requestCount: number; }; diff --git a/src/widgets/media-requests/MediaRequestListTile.tsx b/src/widgets/media-requests/MediaRequestListTile.tsx index 83eccc7a8..eaeced5d6 100644 --- a/src/widgets/media-requests/MediaRequestListTile.tsx +++ b/src/widgets/media-requests/MediaRequestListTile.tsx @@ -6,6 +6,7 @@ import { Flex, Group, Image, + ScrollArea, Stack, Text, Tooltip, @@ -30,6 +31,10 @@ const definition = defineWidget({ type: 'switch', defaultValue: true, }, + openInNewTab: { + type: 'switch', + defaultValue: true, + }, }, component: MediaRequestListTile, gridstack: { @@ -55,7 +60,8 @@ const useMediaRequestDecisionMutation = () => { const utils = api.useContext(); const { mutateAsync } = api.overseerr.decide.useMutation({ onSuccess() { - utils.mediaRequest.all.invalidate(); + utils.mediaRequest.allMedia.invalidate(); + utils.mediaRequest.users.invalidate(); }, }); const { t } = useTranslation('modules/media-requests-list'); @@ -93,7 +99,7 @@ const useMediaRequestDecisionMutation = () => { function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) { const { t } = useTranslation('modules/media-requests-list'); - const { data, isLoading } = useMediaRequestQuery(); + const { data, isLoading } = useMediaRequestQuery(widget); // Use mutation to approve or deny a pending request const decideAsync = useMediaRequestDecisionMutation(); @@ -125,115 +131,118 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) { }); return ( - - {countPendingApproval > 0 ? ( - {t('pending', { countPendingApproval })} - ) : ( - {t('nonePending')} - )} - {sortedData.map((item) => ( - - - - poster - - - {item.airDate && {item.airDate.split('-')[0]}} - - - - {item.name} - - - - - + + + {countPendingApproval > 0 ? ( + {t('pending', { countPendingApproval })} + ) : ( + {t('nonePending')} + )} + {sortedData.map((item) => ( + + + requester avatar - - {item.userName} - + + + {item.airDate && {item.airDate.split('-')[0]}} + + + + {item.name} + + + + + requester avatar + + {item.userName} + + - {item.status === MediaRequestStatus.PendingApproval && ( - - - { - notifications.show({ - id: `approve ${item.id}`, - color: 'yellow', - title: t('tooltips.approving'), - message: undefined, - loading: true, - }); + {item.status === MediaRequestStatus.PendingApproval && ( + + + { + notifications.show({ + id: `approve ${item.id}`, + color: 'yellow', + title: t('tooltips.approving'), + message: undefined, + loading: true, + }); - await decideAsync({ - request: item, - isApproved: true, - }); - }} - > - - - - - { - await decideAsync({ - request: item, - isApproved: false, - }); - }} - > - - - - - )} - - + await decideAsync({ + request: item, + isApproved: true, + }); + }} + > + + + + + { + await decideAsync({ + request: item, + isApproved: false, + }); + }} + > + + + + + )} + + - - - ))} - + + + ))} + + ); } diff --git a/src/widgets/media-requests/MediaRequestStatsTile.tsx b/src/widgets/media-requests/MediaRequestStatsTile.tsx index a46bbe542..b8bfc9acf 100644 --- a/src/widgets/media-requests/MediaRequestStatsTile.tsx +++ b/src/widgets/media-requests/MediaRequestStatsTile.tsx @@ -1,29 +1,40 @@ -import { Card, Flex, Stack, Text } from '@mantine/core'; -import { IconChartBar } from '@tabler/icons-react'; +import { + Avatar, + Card, + Flex, + Group, + Indicator, + Stack, + Text, + Tooltip, + useMantineTheme, +} from '@mantine/core'; +import { useElementSize } from '@mantine/hooks'; +import { IconChartBar, IconExternalLink } from '@tabler/icons-react'; import { useTranslation } from 'next-i18next'; import { defineWidget } from '../helper'; import { WidgetLoading } from '../loading'; import { IWidget } from '../widgets'; -import { useMediaRequestQuery } from './media-request-query'; +import { useMediaRequestQuery, useUsersQuery } from './media-request-query'; import { MediaRequestStatus } from './media-request-types'; const definition = defineWidget({ id: 'media-requests-stats', icon: IconChartBar, options: { - direction: { - type: 'select', - defaultValue: 'row' as 'row' | 'column', - data: [ - { value: 'row' }, - { value: 'column' }, - ], + replaceLinksWithExternalHost: { + type: 'switch', + defaultValue: false, + }, + openInNewTab: { + type: 'switch', + defaultValue: true, }, }, gridstack: { - minWidth: 1, - minHeight: 1, + minWidth: 2, + minHeight: 2, maxWidth: 12, maxHeight: 12, }, @@ -38,53 +49,134 @@ interface MediaRequestStatsWidgetProps { function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) { const { t } = useTranslation('modules/media-requests-stats'); - const { data, isFetching } = useMediaRequestQuery(); + const { + data: mediaData, + isFetching: mediaFetching, + isLoading: mediaLoading, + } = useMediaRequestQuery(widget); + const { + data: usersData, + isFetching: usersFetching, + isLoading: usersLoading + } = useUsersQuery(widget); + const { ref, height } = useElementSize(); + const { colorScheme } = useMantineTheme(); - if (!data || isFetching) { - return ; + if (!mediaData || !usersData || mediaLoading || usersLoading) { + return ( + + + + ); } + const appList: string[] = []; + mediaData.forEach((item) => { + if (!appList.includes(item.appId)) appList.push(item.appId); + }); + + const baseStats: { label: string; number: number }[] = [ + { + label: t('mediaStats.pending'), + number: mediaData.filter((x) => x.status === MediaRequestStatus.PendingApproval).length, + }, + { + label: t('mediaStats.tvRequests'), + number: mediaData.filter((x) => x.type === 'tv').length, + }, + { + label: t('mediaStats.movieRequests'), + number: mediaData.filter((x) => x.type === 'movie').length, + }, + { + label: t('mediaStats.approved'), + number: mediaData.filter((x) => x.status === MediaRequestStatus.Approved).length, + }, + { + label: t('mediaStats.totalRequests'), + number: mediaData.length, + }, + ]; + + const users = usersData + .sort((x, y) => (x.userRequestCount > y.userRequestCount ? -1 : 1)) + .slice(0, Math.trunc(height / 60)); + return ( - - x.status === MediaRequestStatus.PendingApproval).length} - label={t('stats.pending')} - /> - x.type === 'tv').length} - label={t('stats.tvRequests')} - /> - x.type === 'movie').length} - label={t('stats.movieRequests')} - /> + + {t('mediaStats.title')} + + {baseStats.map((stat, index) => { + return ( + + + {stat.label} + + + {stat.number} + + + ); + })} + + {t('userStats.title')} + + {users.map((user) => { + return ( + + + {appList.length > 1 && ( + + + + )} + + + {user.userName} + + {t('userStats.requests', { number: user.userRequestCount })} + + + + + + ); + })} + ); } -interface StatCardProps { - number: number; - label: string; -} - -const StatCard = ({ number, label }: StatCardProps) => { - return ( - - - - {number} - - - {label} - - - - ); -}; - -export default definition; \ No newline at end of file +export default definition; diff --git a/src/widgets/media-requests/media-request-query.tsx b/src/widgets/media-requests/media-request-query.tsx index 3c9f2f486..15a149da0 100644 --- a/src/widgets/media-requests/media-request-query.tsx +++ b/src/widgets/media-requests/media-request-query.tsx @@ -1,10 +1,22 @@ import { useConfigContext } from '~/config/provider'; +import { MediaRequestListWidget } from './MediaRequestListTile'; +import { MediaRequestStatsWidget } from './MediaRequestStatsTile'; import { api } from '~/utils/api'; -export const useMediaRequestQuery = () => { +export const useMediaRequestQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => { const { name: configName } = useConfigContext(); - return api.mediaRequest.all.useQuery( - { configName: configName! }, + return api.mediaRequest.allMedia.useQuery( + { configName: configName!, widget: widget }, + { + refetchInterval: 3 * 60 * 1000, + } + ); +}; + +export const useUsersQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => { + const { name: configName } = useConfigContext(); + return api.mediaRequest.users.useQuery( + { configName: configName!, widget: widget }, { refetchInterval: 3 * 60 * 1000, } diff --git a/src/widgets/media-requests/media-request-types.tsx b/src/widgets/media-requests/media-request-types.tsx index 9827dce3a..4df345a81 100644 --- a/src/widgets/media-requests/media-request-types.tsx +++ b/src/widgets/media-requests/media-request-types.tsx @@ -8,6 +8,7 @@ export type MediaRequest = { userName: string; userProfilePicture: string; userLink: string; + userRequestCount: number; airDate?: string; status: MediaRequestStatus; backdropPath: string; @@ -15,6 +16,15 @@ export type MediaRequest = { href: string; }; +export type Users = { + app: string; + id: number; + userName: string; + userProfilePicture: string; + userLink: string; + userRequestCount: number; +}; + export enum MediaRequestStatus { PendingApproval = 1, Approved = 2,