Rework Media Request Stats Widget (#1344)

*  Rework Media Request Stats Widget

* 🎨 More code to do it better than last commit

* ♻️ Resize improvement

* 🐛 Empty Username handling

* 🎨 widget as router input

*  Open links in new tab + media request scrollArea
This commit is contained in:
Tagaishi
2023-09-01 22:15:40 +02:00
committed by GitHub
parent 1bb1a8f628
commit 371587c62d
7 changed files with 368 additions and 188 deletions

View File

@@ -6,6 +6,9 @@
"title": "Media requests list", "title": "Media requests list",
"replaceLinksWithExternalHost": { "replaceLinksWithExternalHost": {
"label": "Replace links with external host" "label": "Replace links with external host"
},
"openInNewTab": {
"label": "Open links in new tab"
} }
} }
}, },

View File

@@ -4,18 +4,24 @@
"description": "Statistics about your media requests", "description": "Statistics about your media requests",
"settings": { "settings": {
"title": "Media requests stats", "title": "Media requests stats",
"direction": { "replaceLinksWithExternalHost": {
"label": "Direction of the layout.", "label": "Replace links with external host"
"data":{ },
"row": "Horizontal", "openInNewTab": {
"column": "Vertical" "label": "Open links in new tab"
}
} }
} }
}, },
"stats": { "mediaStats": {
"title": "Media Stats",
"pending": "Pending approvals", "pending": "Pending approvals",
"tvRequests": "TV requests", "tvRequests": "TV requests",
"movieRequests": "Movie requests" "movieRequests": "Movie requests",
"approved": "Already approved",
"totalRequests": "Total"
},
"userStats": {
"title": "Top Users",
"requests": "Requests: {{number}}"
} }
} }

View File

@@ -3,15 +3,17 @@ import { z } from 'zod';
import { checkIntegrationsType } from '~/tools/client/app-properties'; import { checkIntegrationsType } from '~/tools/client/app-properties';
import { getConfig } from '~/tools/config/getConfig'; import { getConfig } from '~/tools/config/getConfig';
import { MediaRequestListWidget } from '~/widgets/media-requests/MediaRequestListTile'; 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 { createTRPCRouter, publicProcedure } from '../trpc';
import { MediaRequestStatsWidget } from '~/widgets/media-requests/MediaRequestStatsTile';
export const mediaRequestsRouter = createTRPCRouter({ export const mediaRequestsRouter = createTRPCRouter({
all: publicProcedure allMedia: publicProcedure
.input( .input(
z.object({ z.object({
configName: z.string(), configName: z.string(),
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
}) })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
@@ -21,8 +23,6 @@ export const mediaRequestsRouter = createTRPCRouter({
checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr']) checkIntegrationsType(app.integration, ['overseerr', 'jellyseerr'])
); );
Consola.log(`Retrieving media requests from ${apps.length} apps`);
const promises = apps.map((app): Promise<MediaRequest[]> => { const promises = apps.map((app): Promise<MediaRequest[]> => {
const apiKey = const apiKey =
app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? ''; app.integration?.properties.find((prop) => prop.field === 'apiKey')?.value ?? '';
@@ -32,14 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
}) })
.then(async (response) => { .then(async (response) => {
const body = (await response.json()) as OverseerrResponse; const body = (await response.json()) as OverseerrResponse;
const mediaWidget = config.widgets.find((x) => x.type === 'media-requests-list') as const appUrl = input.widget.properties.replaceLinksWithExternalHost
| MediaRequestListWidget
| undefined;
if (!mediaWidget) {
Consola.log('No media-requests-list found');
return Promise.resolve([]);
}
const appUrl = mediaWidget.properties.replaceLinksWithExternalHost
? app.behaviour.externalUrl ? app.behaviour.externalUrl
: app.url; : app.url;
@@ -59,8 +52,9 @@ export const mediaRequestsRouter = createTRPCRouter({
type: item.type, type: item.type,
name: genericItem.name, name: genericItem.name,
userName: item.requestedBy.displayName, userName: item.requestedBy.displayName,
userProfilePicture: constructAvatarUrl(appUrl, item), userProfilePicture: constructAvatarUrl(appUrl, item.requestedBy.avatar),
userLink: `${appUrl}/users/${item.requestedBy.id}`, userLink: `${appUrl}/users/${item.requestedBy.id}`,
userRequestCount: item.requestedBy.requestCount,
airDate: genericItem.airDate, airDate: genericItem.airDate,
status: item.status, status: item.status,
backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`, backdropPath: `https://image.tmdb.org/t/p/original/${genericItem.backdropPath}`,
@@ -85,17 +79,66 @@ export const mediaRequestsRouter = createTRPCRouter({
return mediaRequests; return mediaRequests;
}), }),
users: publicProcedure
.input(
z.object({
configName: z.string(),
widget: z.custom<MediaRequestListWidget>().or(z.custom<MediaRequestStatsWidget>()),
})
)
.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<Users[]> => {
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<Users> => {
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 constructAvatarUrl = (appUrl: string, avatar: string) => {
const isAbsolute = const isAbsolute = avatar.startsWith('http://') || avatar.startsWith('https://');
item.requestedBy.avatar.startsWith('http://') || item.requestedBy.avatar.startsWith('https://');
if (isAbsolute) { if (isAbsolute) {
return item.requestedBy.avatar; return avatar;
} }
return `${appUrl}/${item.requestedBy.avatar}`; return `${appUrl}/${avatar}`;
}; };
const retrieveDetailsForItem = async ( const retrieveDetailsForItem = async (
@@ -117,7 +160,7 @@ const retrieveDetailsForItem = async (
backdropPath: series.backdropPath, backdropPath: series.backdropPath,
posterPath: series.backdropPath, posterPath: series.backdropPath,
}; };
} };
const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, { const movieResponse = await fetch(`${baseUrl}/api/v1/movie/${id}`, {
headers, headers,
@@ -158,6 +201,10 @@ type OverseerrResponse = {
results: OverseerrResponseItem[]; results: OverseerrResponseItem[];
}; };
type OverseerrUsers = {
results: OverseerrResponseItemUser[];
};
type OverseerrResponseItem = { type OverseerrResponseItem = {
id: number; id: number;
status: number; status: number;
@@ -176,4 +223,5 @@ type OverseerrResponseItemUser = {
id: number; id: number;
displayName: string; displayName: string;
avatar: string; avatar: string;
requestCount: number;
}; };

View File

@@ -6,6 +6,7 @@ import {
Flex, Flex,
Group, Group,
Image, Image,
ScrollArea,
Stack, Stack,
Text, Text,
Tooltip, Tooltip,
@@ -30,6 +31,10 @@ const definition = defineWidget({
type: 'switch', type: 'switch',
defaultValue: true, defaultValue: true,
}, },
openInNewTab: {
type: 'switch',
defaultValue: true,
},
}, },
component: MediaRequestListTile, component: MediaRequestListTile,
gridstack: { gridstack: {
@@ -55,7 +60,8 @@ const useMediaRequestDecisionMutation = () => {
const utils = api.useContext(); const utils = api.useContext();
const { mutateAsync } = api.overseerr.decide.useMutation({ const { mutateAsync } = api.overseerr.decide.useMutation({
onSuccess() { onSuccess() {
utils.mediaRequest.all.invalidate(); utils.mediaRequest.allMedia.invalidate();
utils.mediaRequest.users.invalidate();
}, },
}); });
const { t } = useTranslation('modules/media-requests-list'); const { t } = useTranslation('modules/media-requests-list');
@@ -93,7 +99,7 @@ const useMediaRequestDecisionMutation = () => {
function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) { function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
const { t } = useTranslation('modules/media-requests-list'); 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 // Use mutation to approve or deny a pending request
const decideAsync = useMediaRequestDecisionMutation(); const decideAsync = useMediaRequestDecisionMutation();
@@ -125,115 +131,118 @@ function MediaRequestListTile({ widget }: MediaRequestListWidgetProps) {
}); });
return ( return (
<Stack> <ScrollArea h="100%">
{countPendingApproval > 0 ? ( <Stack>
<Text>{t('pending', { countPendingApproval })}</Text> {countPendingApproval > 0 ? (
) : ( <Text>{t('pending', { countPendingApproval })}</Text>
<Text>{t('nonePending')}</Text> ) : (
)} <Text>{t('nonePending')}</Text>
{sortedData.map((item) => ( )}
<Card withBorder> {sortedData.map((item) => (
<Flex wrap="wrap" justify="space-between" gap="md"> <Card radius="md" withBorder>
<Flex gap="md"> <Flex wrap="wrap" justify="space-between" gap="md">
<Image <Flex gap="md">
src={item.posterPath}
width={30}
height={50}
alt="poster"
radius="xs"
withPlaceholder
/>
<Stack spacing={0}>
<Group spacing="xs">
{item.airDate && <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>
<Stack justify="center">
<Flex gap="xs">
<Image <Image
src={item.userProfilePicture} src={item.posterPath}
width={25} width={30}
height={25} height={50}
alt="requester avatar" alt="poster"
radius="xl" radius="xs"
withPlaceholder withPlaceholder
/> />
<Text <Stack spacing={0}>
component="a" <Group spacing="xs">
href={item.userLink} {item.airDate && <Text>{item.airDate.split('-')[0]}</Text>}
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }} <MediaRequestStatusBadge status={item.status} />
> </Group>
{item.userName} <Text
</Text> sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
lineClamp={1}
weight="bold"
component="a"
href={item.href}
>
{item.name}
</Text>
</Stack>
</Flex> </Flex>
<Stack justify="center">
<Flex gap="xs">
<Image
src={item.userProfilePicture}
width={25}
height={25}
alt="requester avatar"
radius="xl"
withPlaceholder
/>
<Text
component="a"
href={item.userLink}
target={widget.properties.openInNewTab ? "_blank" : "_self"}
sx={{ cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
>
{item.userName}
</Text>
</Flex>
{item.status === MediaRequestStatus.PendingApproval && ( {item.status === MediaRequestStatus.PendingApproval && (
<Group> <Group>
<Tooltip label={t('tooltips.approve')} withArrow withinPortal> <Tooltip label={t('tooltips.approve')} withArrow withinPortal>
<ActionIcon <ActionIcon
variant="light" variant="light"
color="green" color="green"
onClick={async () => { onClick={async () => {
notifications.show({ notifications.show({
id: `approve ${item.id}`, id: `approve ${item.id}`,
color: 'yellow', color: 'yellow',
title: t('tooltips.approving'), title: t('tooltips.approving'),
message: undefined, message: undefined,
loading: true, loading: true,
}); });
await decideAsync({ await decideAsync({
request: item, request: item,
isApproved: true, isApproved: true,
}); });
}} }}
> >
<IconThumbUp /> <IconThumbUp />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t('tooltips.decline')} withArrow withinPortal> <Tooltip label={t('tooltips.decline')} withArrow withinPortal>
<ActionIcon <ActionIcon
variant="light" variant="light"
color="red" color="red"
onClick={async () => { onClick={async () => {
await decideAsync({ await decideAsync({
request: item, request: item,
isApproved: false, isApproved: false,
}); });
}} }}
> >
<IconThumbDown /> <IconThumbDown />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
</Group> </Group>
)} )}
</Stack> </Stack>
</Flex> </Flex>
<Image <Image
src={item.backdropPath} src={item.backdropPath}
pos="absolute" pos="absolute"
w="100%" w="100%"
h="100%" h="100%"
opacity={0.1} opacity={0.1}
top={0} top={0}
left={0} left={0}
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
/> />
</Card> </Card>
))} ))}
</Stack> </Stack>
</ScrollArea>
); );
} }

View File

@@ -1,29 +1,40 @@
import { Card, Flex, Stack, Text } from '@mantine/core'; import {
import { IconChartBar } from '@tabler/icons-react'; 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 { useTranslation } from 'next-i18next';
import { defineWidget } from '../helper'; import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading'; import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets'; import { IWidget } from '../widgets';
import { useMediaRequestQuery } from './media-request-query'; import { useMediaRequestQuery, useUsersQuery } from './media-request-query';
import { MediaRequestStatus } from './media-request-types'; import { MediaRequestStatus } from './media-request-types';
const definition = defineWidget({ const definition = defineWidget({
id: 'media-requests-stats', id: 'media-requests-stats',
icon: IconChartBar, icon: IconChartBar,
options: { options: {
direction: { replaceLinksWithExternalHost: {
type: 'select', type: 'switch',
defaultValue: 'row' as 'row' | 'column', defaultValue: false,
data: [ },
{ value: 'row' }, openInNewTab: {
{ value: 'column' }, type: 'switch',
], defaultValue: true,
}, },
}, },
gridstack: { gridstack: {
minWidth: 1, minWidth: 2,
minHeight: 1, minHeight: 2,
maxWidth: 12, maxWidth: 12,
maxHeight: 12, maxHeight: 12,
}, },
@@ -38,53 +49,134 @@ interface MediaRequestStatsWidgetProps {
function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) { function MediaRequestStatsTile({ widget }: MediaRequestStatsWidgetProps) {
const { t } = useTranslation('modules/media-requests-stats'); 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) { if (!mediaData || !usersData || mediaLoading || usersLoading) {
return <WidgetLoading />; return (
<Stack ref={ref} h="100%">
<WidgetLoading />
</Stack>
);
} }
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 ( return (
<Flex <Flex h="100%" gap={0} direction="column">
w="100%" <Text mt={-5}>{t('mediaStats.title')}</Text>
h="100%" <Card py={5} px={10} radius="md" style={{ overflow: 'unset' }} withBorder>
gap="md" {baseStats.map((stat, index) => {
direction={ widget.properties.direction?? 'row' } return (
> <Group key={index} position="apart">
<StatCard <Text color="dimmed" align="center" size="xs">
number={data.filter((x) => x.status === MediaRequestStatus.PendingApproval).length} {stat.label}
label={t('stats.pending')} </Text>
/> <Text align="center" size="xs">
<StatCard {stat.number}
number={data.filter((x) => x.type === 'tv').length} </Text>
label={t('stats.tvRequests')} </Group>
/> );
<StatCard })}
number={data.filter((x) => x.type === 'movie').length} </Card>
label={t('stats.movieRequests')} <Text mt={2}>{t('userStats.title')}</Text>
/> <Stack ref={ref} style={{ flex: 1 }} spacing={5} p={0} sx={{ overflow: 'hidden' }}>
{users.map((user) => {
return (
<Card
key={user.id}
p={0}
component="a"
href={user.userLink}
target={widget.properties.openInNewTab ? "_blank" : "_self"}
mah={95}
mih={55}
radius="md"
style={{ flex: 1 }}
withBorder
>
<Group
spacing={5}
px={10}
py={5}
align="center"
h="100%"
display="flex"
style={{ flexDirection: 'row' }}
>
{appList.length > 1 && (
<Tooltip.Floating
label={user.app.charAt(0).toUpperCase() + user.app.slice(1)}
c={colorScheme === 'light' ? 'black' : 'dark.0'}
color={colorScheme === 'light' ? 'gray.2' : 'dark.4'}
>
<Indicator
withBorder
top={18}
left={8}
size={15}
ml={-5}
color={user.app === 'overseerr' ? '#ECB000' : '#6677CC'}
processing={mediaFetching || usersFetching}
children
/>
</Tooltip.Floating>
)}
<Avatar radius="xl" size={45} src={user.userProfilePicture} alt="user avatar" />
<Stack spacing={0} style={{ flex: 1 }}>
<Text>{user.userName}</Text>
<Text size="xs">
{t('userStats.requests', { number: user.userRequestCount })}
</Text>
</Stack>
<IconExternalLink size={20} />
</Group>
</Card>
);
})}
</Stack>
</Flex> </Flex>
); );
} }
interface StatCardProps { export default definition;
number: number;
label: string;
}
const StatCard = ({ number, label }: StatCardProps) => {
return (
<Card w="100%" h="100%" withBorder style={{flex:"1 1 auto"}}>
<Stack w="100%" h="100%" align="center" justify="center" spacing={0}>
<Text align="center">
{number}
</Text>
<Text color="dimmed" align="center" size="xs">
{label}
</Text>
</Stack>
</Card>
);
};
export default definition;

View File

@@ -1,10 +1,22 @@
import { useConfigContext } from '~/config/provider'; import { useConfigContext } from '~/config/provider';
import { MediaRequestListWidget } from './MediaRequestListTile';
import { MediaRequestStatsWidget } from './MediaRequestStatsTile';
import { api } from '~/utils/api'; import { api } from '~/utils/api';
export const useMediaRequestQuery = () => { export const useMediaRequestQuery = (widget: MediaRequestListWidget|MediaRequestStatsWidget) => {
const { name: configName } = useConfigContext(); const { name: configName } = useConfigContext();
return api.mediaRequest.all.useQuery( return api.mediaRequest.allMedia.useQuery(
{ configName: configName! }, { 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, refetchInterval: 3 * 60 * 1000,
} }

View File

@@ -8,6 +8,7 @@ export type MediaRequest = {
userName: string; userName: string;
userProfilePicture: string; userProfilePicture: string;
userLink: string; userLink: string;
userRequestCount: number;
airDate?: string; airDate?: string;
status: MediaRequestStatus; status: MediaRequestStatus;
backdropPath: string; backdropPath: string;
@@ -15,6 +16,15 @@ export type MediaRequest = {
href: string; href: string;
}; };
export type Users = {
app: string;
id: number;
userName: string;
userProfilePicture: string;
userLink: string;
userRequestCount: number;
};
export enum MediaRequestStatus { export enum MediaRequestStatus {
PendingApproval = 1, PendingApproval = 1,
Approved = 2, Approved = 2,