diff --git a/.all-contributorsrc b/.all-contributorsrc index 610aebc90..8dbd97d6a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -94,7 +94,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/345752?v=4", "profile": "https://github.com/jab416171", "contributions": [ - "doc" + "doc", + "code" ] }, { @@ -620,6 +621,87 @@ "contributions": [ "code" ] + }, + { + "login": "j0srisk", + "name": "Joseph Risk", + "avatar_url": "https://avatars.githubusercontent.com/u/18372584?v=4", + "profile": "http://josephrisk.com", + "contributions": [ + "code" + ] + }, + { + "login": "Loetwiek", + "name": "Loetwiek", + "avatar_url": "https://avatars.githubusercontent.com/u/79059734?v=4", + "profile": "https://github.com/Loetwiek", + "contributions": [ + "code" + ] + }, + { + "login": "Fuochi", + "name": "Fuochi", + "avatar_url": "https://avatars.githubusercontent.com/u/4720478?v=4", + "profile": "https://github.com/Fuochi", + "contributions": [ + "doc" + ] + }, + { + "login": "demrich", + "name": "David Emrich", + "avatar_url": "https://avatars.githubusercontent.com/u/30092389?v=4", + "profile": "https://github.com/demrich", + "contributions": [ + "code" + ] + }, + { + "login": "maxnatamo", + "name": "Max T. Kristiansen", + "avatar_url": "https://avatars.githubusercontent.com/u/5898152?v=4", + "profile": "https://maxtrier.dk", + "contributions": [ + "code" + ] + }, + { + "login": "DamsDev1", + "name": "Damien Fajole", + "avatar_url": "https://avatars.githubusercontent.com/u/60252259?v=4", + "profile": "https://damsdev.me", + "contributions": [ + "code" + ] + }, + { + "login": "AhmedNSidd", + "name": "Ahmed Siddiqui", + "avatar_url": "https://avatars.githubusercontent.com/u/36286128?v=4", + "profile": "https://github.com/AhmedNSidd", + "contributions": [ + "code" + ] + }, + { + "login": "JackW6809", + "name": "JackOXI", + "avatar_url": "https://avatars.githubusercontent.com/u/53652452?v=4", + "profile": "https://github.com/JackW6809", + "contributions": [ + "code" + ] + }, + { + "login": "StancuFlorin", + "name": "Stancu Florin", + "avatar_url": "https://avatars.githubusercontent.com/u/1199404?v=4", + "profile": "http://indicus.ro", + "contributions": [ + "code" + ] } ] } diff --git a/README.md b/README.md index 4cc83f494..8bb9c7c9b 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon byakurau
byakurau

🌍 miknii
miknii

🌍 Mackenzie
Mackenzie

💻 - soup
soup

📖 + soup
soup

📖 💻 ceptonit
ceptonit

📖 aedelbro
aedelbro

💻 @@ -321,6 +321,8 @@ Thanks goes to these wonderful people from Overseerr ([emoji key](https://allcon Ahmed Siddiqui
Ahmed Siddiqui

💻 + JackOXI
JackOXI

💻 + Stancu Florin
Stancu Florin

💻 diff --git a/public/sw.js b/public/sw.js index 6a89315ac..3aec6343f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,7 +3,7 @@ // previously cached resources to be updated from the network. // This variable is intentionally declared and unused. // eslint-disable-next-line @typescript-eslint/no-unused-vars -const OFFLINE_VERSION = 3; +const OFFLINE_VERSION = 4; const CACHE_NAME = 'offline'; // Customize this with a different URL if needed. const OFFLINE_URL = '/offline.html'; @@ -107,6 +107,25 @@ self.addEventListener('push', (event) => { ); } + // Set the badge with the amount of pending requests + // Only update the badge if the payload confirms they are the admin + if ( + (payload.notificationType === 'MEDIA_APPROVED' || + payload.notificationType === 'MEDIA_DECLINED') && + payload.isAdmin + ) { + if ('setAppBadge' in navigator) { + navigator.setAppBadge(payload.pendingRequestsCount); + } + return; + } + + if (payload.notificationType === 'MEDIA_PENDING') { + if ('setAppBadge' in navigator) { + navigator.setAppBadge(payload.pendingRequestsCount); + } + } + event.waitUntil(self.registration.showNotification(payload.subject, options)); }); diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 5cf449ea9..cb4d40711 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -256,6 +256,7 @@ class TheMovieDb extends ExternalAPI { language, append_to_response: 'credits,external_ids,videos,keywords,release_dates,watch/providers', + include_video_language: language + ', en', }, 43200 ); @@ -280,6 +281,7 @@ class TheMovieDb extends ExternalAPI { language, append_to_response: 'aggregate_credits,credits,external_ids,keywords,videos,content_ratings,watch/providers', + include_video_language: language + ', en', }, 43200 ); diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index d2b0b1656..952e1acf0 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -19,6 +19,8 @@ export interface NotificationPayload { request?: MediaRequest; issue?: Issue; comment?: IssueComment; + pendingRequestsCount?: number; + isAdmin?: boolean; } export abstract class BaseAgent { diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 275a77e8e..143961ec4 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -1,6 +1,7 @@ import { IssueType, IssueTypeName } from '@server/constants/issue'; -import { MediaType } from '@server/constants/media'; +import { MediaRequestStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import MediaRequest from '@server/entity/MediaRequest'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { NotificationAgentConfig } from '@server/lib/settings'; @@ -19,6 +20,8 @@ interface PushNotificationPayload { actionUrl?: string; actionUrlTitle?: string; requestId?: number; + pendingRequestsCount?: number; + isAdmin?: boolean; } class WebPushAgent @@ -129,6 +132,8 @@ class WebPushAgent requestId: payload.request?.id, actionUrl, actionUrlTitle, + pendingRequestsCount: payload.pendingRequestsCount, + isAdmin: payload.isAdmin, }; } @@ -152,6 +157,51 @@ class WebPushAgent const mainUser = await userRepository.findOne({ where: { id: 1 } }); + const requestRepository = getRepository(MediaRequest); + + const pendingRequests = await requestRepository.find({ + where: { status: MediaRequestStatus.PENDING }, + }); + + const webPushNotification = async ( + pushSub: UserPushSubscription, + notificationPayload: Buffer + ) => { + logger.debug('Sending web push notification', { + label: 'Notifications', + recipient: pushSub.user.displayName, + type: Notification[type], + subject: payload.subject, + }); + + try { + await webpush.sendNotification( + { + endpoint: pushSub.endpoint, + keys: { + auth: pushSub.auth, + p256dh: pushSub.p256dh, + }, + }, + notificationPayload + ); + } catch (e) { + logger.error( + 'Error sending web push notification; removing subscription', + { + label: 'Notifications', + recipient: pushSub.user.displayName, + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + } + ); + + // Failed to send notification so we need to remove the subscription + userPushSubRepository.remove(pushSub); + } + }; + if ( payload.notifyUser && // Check if user has webpush notifications enabled and fallback to true if undefined @@ -169,7 +219,11 @@ class WebPushAgent pushSubs.push(...notifySubs); } - if (payload.notifyAdmin) { + if ( + payload.notifyAdmin || + type === Notification.MEDIA_APPROVED || + type === Notification.MEDIA_DECLINED + ) { const users = await userRepository.find(); const manageUsers = users.filter( @@ -192,7 +246,42 @@ class WebPushAgent }) .getMany(); - pushSubs.push(...allSubs); + // We only want to send the custom notification when type is approved or declined + // Otherwise, default to the normal notification + if ( + type === Notification.MEDIA_APPROVED || + type === Notification.MEDIA_DECLINED + ) { + if (mainUser && allSubs.length > 0) { + webpush.setVapidDetails( + `mailto:${mainUser.email}`, + settings.vapidPublic, + settings.vapidPrivate + ); + + // Custom payload only for updating the app badge + const notificationBadgePayload = Buffer.from( + JSON.stringify( + this.getNotificationPayload(type, { + subject: payload.subject, + notifySystem: false, + notifyAdmin: true, + isAdmin: true, + pendingRequestsCount: pendingRequests.length, + }) + ), + 'utf-8' + ); + + await Promise.all( + allSubs.map(async (sub) => { + webPushNotification(sub, notificationBadgePayload); + }) + ); + } + } else { + pushSubs.push(...allSubs); + } } if (mainUser && pushSubs.length > 0) { @@ -202,6 +291,10 @@ class WebPushAgent settings.vapidPrivate ); + if (type === Notification.MEDIA_PENDING) { + payload = { ...payload, pendingRequestsCount: pendingRequests.length }; + } + const notificationPayload = Buffer.from( JSON.stringify(this.getNotificationPayload(type, payload)), 'utf-8' @@ -209,39 +302,7 @@ class WebPushAgent await Promise.all( pushSubs.map(async (sub) => { - logger.debug('Sending web push notification', { - label: 'Notifications', - recipient: sub.user.displayName, - type: Notification[type], - subject: payload.subject, - }); - - try { - await webpush.sendNotification( - { - endpoint: sub.endpoint, - keys: { - auth: sub.auth, - p256dh: sub.p256dh, - }, - }, - notificationPayload - ); - } catch (e) { - logger.error( - 'Error sending web push notification; removing subscription', - { - label: 'Notifications', - recipient: sub.user.displayName, - type: Notification[type], - subject: payload.subject, - errorMessage: e.message, - } - ); - - // Failed to send notification so we need to remove the subscription - userPushSubRepository.remove(sub); - } + webPushNotification(sub, notificationPayload); }) ); } diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg index 14c5abd9b..53f28d1cd 100644 --- a/src/assets/services/plex.svg +++ b/src/assets/services/plex.svg @@ -1,85 +1,43 @@ - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/Common/SensitiveInput/index.tsx b/src/components/Common/SensitiveInput/index.tsx index ae11b9517..9a92d254f 100644 --- a/src/components/Common/SensitiveInput/index.tsx +++ b/src/components/Common/SensitiveInput/index.tsx @@ -25,6 +25,10 @@ const SensitiveInput = ({ as = 'input', ...props }: SensitiveInputProps) => { return ( <> { autoDismiss: true, }); revalidateIssue(); + mutate('/api/v1/issue/count'); } catch (e) { addToast(intl.formatMessage(messages.toaststatusupdatefailed), { appearance: 'error', @@ -169,6 +170,7 @@ const IssueDetails = () => { method: 'DELETE', }); if (!res.ok) throw new Error(); + mutate('/api/v1/issue/count'); addToast(intl.formatMessage(messages.toastissuedeleted), { appearance: 'success', diff --git a/src/components/IssueModal/CreateIssueModal/index.tsx b/src/components/IssueModal/CreateIssueModal/index.tsx index 8d8803855..58836ef8f 100644 --- a/src/components/IssueModal/CreateIssueModal/index.tsx +++ b/src/components/IssueModal/CreateIssueModal/index.tsx @@ -15,7 +15,7 @@ import { Field, Formik } from 'formik'; import Link from 'next/link'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; import * as Yup from 'yup'; const messages = defineMessages('components.IssueModal.CreateIssueModal', { @@ -138,6 +138,8 @@ const CreateIssueModal = ({ autoDismiss: true, } ); + + mutate('/api/v1/issue/count'); } if (onCancel) { diff --git a/src/components/Layout/MobileMenu/index.tsx b/src/components/Layout/MobileMenu/index.tsx index fe1e2e405..52e84d3de 100644 --- a/src/components/Layout/MobileMenu/index.tsx +++ b/src/components/Layout/MobileMenu/index.tsx @@ -1,3 +1,4 @@ +import Badge from '@app/components/Common/Badge'; import { menuMessages } from '@app/components/Layout/Sidebar'; import useClickOutside from '@app/hooks/useClickOutside'; import { Permission, useUser } from '@app/hooks/useUser'; @@ -26,9 +27,16 @@ import { } from '@heroicons/react/24/solid'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { cloneElement, useRef, useState } from 'react'; +import { cloneElement, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; +interface MobileMenuProps { + pendingRequestsCount: number; + openIssuesCount: number; + revalidateIssueCount: () => void; + revalidateRequestsCount: () => void; +} + interface MenuLink { href: string; svgIcon: JSX.Element; @@ -41,7 +49,12 @@ interface MenuLink { dataTestId?: string; } -const MobileMenu = () => { +const MobileMenu = ({ + pendingRequestsCount, + openIssuesCount, + revalidateIssueCount, + revalidateRequestsCount, +}: MobileMenuProps) => { const ref = useRef(null); const intl = useIntl(); const [isOpen, setIsOpen] = useState(false); @@ -139,6 +152,21 @@ const MobileMenu = () => { }) ); + useEffect(() => { + if (openIssuesCount) { + revalidateIssueCount(); + } + + if (pendingRequestsCount) { + revalidateRequestsCount(); + } + }, [ + revalidateIssueCount, + revalidateRequestsCount, + pendingRequestsCount, + openIssuesCount, + ]); + return (
{ { @@ -174,7 +202,25 @@ const MobileMenu = () => { {cloneElement(isActive ? link.svgIconSelected : link.svgIcon, { className: 'h-5 w-5', })} - {link.content} + {link.content} + {link.href === '/requests' && + pendingRequestsCount > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && ( +
+ + {pendingRequestsCount} + +
+ )} + {link.href === '/issues' && + openIssuesCount > 0 && + hasPermission(Permission.MANAGE_ISSUES) && ( +
+ + {openIssuesCount} + +
+ )} ); })} @@ -190,7 +236,7 @@ const MobileMenu = () => { @@ -200,6 +246,23 @@ const MobileMenu = () => { className: 'h-6 w-6', } )} + {link.href === '/requests' && + pendingRequestsCount > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && ( +
+ + {pendingRequestsCount > 99 + ? '99+' + : pendingRequestsCount} + +
+ )} ); })} diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index a947e2626..d578bef85 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -1,3 +1,4 @@ +import Badge from '@app/components/Common/Badge'; import UserWarnings from '@app/components/Layout/UserWarnings'; import VersionStatus from '@app/components/Layout/VersionStatus'; import useClickOutside from '@app/hooks/useClickOutside'; @@ -18,7 +19,7 @@ import { import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { Fragment, useRef } from 'react'; +import { Fragment, useEffect, useRef } from 'react'; import { useIntl } from 'react-intl'; export const menuMessages = defineMessages('components.Layout.Sidebar', { @@ -35,6 +36,10 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { interface SidebarProps { open?: boolean; setClosed: () => void; + pendingRequestsCount: number; + openIssuesCount: number; + revalidateIssueCount: () => void; + revalidateRequestsCount: () => void; } interface SidebarLinkProps { @@ -114,13 +119,35 @@ const SidebarLinks: SidebarLinkProps[] = [ }, ]; -const Sidebar = ({ open, setClosed }: SidebarProps) => { +const Sidebar = ({ + open, + setClosed, + pendingRequestsCount, + openIssuesCount, + revalidateIssueCount, + revalidateRequestsCount, +}: SidebarProps) => { const navRef = useRef(null); const router = useRouter(); const intl = useIntl(); const { hasPermission } = useUser(); useClickOutside(navRef, () => setClosed()); + useEffect(() => { + if (openIssuesCount) { + revalidateIssueCount(); + } + + if (pendingRequestsCount) { + revalidateRequestsCount(); + } + }, [ + revalidateIssueCount, + revalidateRequestsCount, + pendingRequestsCount, + openIssuesCount, + ]); + return ( <>
@@ -253,18 +280,48 @@ const Sidebar = ({ open, setClosed }: SidebarProps) => { href={sidebarLink.href} as={sidebarLink.as} className={`group flex items-center rounded-md px-2 py-2 text-lg font-medium leading-6 text-white transition duration-150 ease-in-out focus:outline-none - ${ - router.pathname.match(sidebarLink.activeRegExp) - ? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500' - : 'hover:bg-gray-700 focus:bg-gray-700' - } - `} + ${ + router.pathname.match(sidebarLink.activeRegExp) + ? 'bg-gradient-to-br from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500' + : 'hover:bg-gray-700 focus:bg-gray-700' + } + `} data-testid={sidebarLink.dataTestId} > {sidebarLink.svgIcon} {intl.formatMessage( menuMessages[sidebarLink.messagesKey] )} + {sidebarLink.messagesKey === 'requests' && + pendingRequestsCount > 0 && + hasPermission(Permission.MANAGE_REQUESTS) && ( +
+ + {pendingRequestsCount} + +
+ )} + {sidebarLink.messagesKey === 'issues' && + openIssuesCount > 0 && + hasPermission(Permission.MANAGE_ISSUES) && ( +
+ + {openIssuesCount} + +
+ )} ); })} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index a1964b0ba..50d463cf0 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -10,6 +10,7 @@ import { useUser } from '@app/hooks/useUser'; import { ArrowLeftIcon, Bars3BottomLeftIcon } from '@heroicons/react/24/solid'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; +import useSWR from 'swr'; type LayoutProps = { children: React.ReactNode; @@ -22,6 +23,18 @@ const Layout = ({ children }: LayoutProps) => { const router = useRouter(); const { currentSettings } = useSettings(); const { setLocale } = useLocale(); + const { data: requestResponse, mutate: revalidateRequestsCount } = useSWR( + '/api/v1/request/count', + { + revalidateOnMount: true, + } + ); + const { data: issueResponse, mutate: revalidateIssueCount } = useSWR( + '/api/v1/issue/count', + { + revalidateOnMount: true, + } + ); useEffect(() => { if (setLocale && user) { @@ -55,10 +68,21 @@ const Layout = ({ children }: LayoutProps) => {
- - setSidebarOpen(false)} /> + setSidebarOpen(false)} + pendingRequestsCount={requestResponse?.pending ?? 0} + openIssuesCount={issueResponse?.open ?? 0} + revalidateIssueCount={() => revalidateIssueCount()} + revalidateRequestsCount={() => revalidateRequestsCount()} + />
- + revalidateIssueCount()} + revalidateRequestsCount={() => revalidateRequestsCount()} + />
diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 2372bc7f0..74bc1e393 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -114,6 +114,9 @@ const LocalLogin = ({ revalidate }: LocalLoginProps) => { autoComplete="current-password" data-testid="password" className="!bg-gray-700/80 placeholder:text-gray-400" + data-1pignore="false" + data-lpignore="false" + data-bwignore="false" />
diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index b63a24dd6..7c7494de8 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -20,6 +20,7 @@ import type { MediaRequest } from '@server/entity/MediaRequest'; import Link from 'next/link'; import { useState } from 'react'; import { useIntl } from 'react-intl'; +import { mutate } from 'swr'; const messages = defineMessages('components.RequestBlock', { seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -59,6 +60,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { if (onUpdate) { onUpdate(); + mutate('/api/v1/request/count'); } setIsUpdating(false); }; @@ -72,6 +74,7 @@ const RequestBlock = ({ request, onUpdate }: RequestBlockProps) => { if (onUpdate) { onUpdate(); + mutate('/api/v1/request/count'); } setIsUpdating(false); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index cbe04fe3d..957a33904 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -15,6 +15,7 @@ import type Media from '@server/entity/Media'; import type { MediaRequest } from '@server/entity/MediaRequest'; import { useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; +import { mutate } from 'swr'; const messages = defineMessages('components.RequestButton', { viewrequest: 'View Request', @@ -101,6 +102,7 @@ const RequestButton = ({ if (data) { onUpdate(); + mutate('/api/v1/request/count'); } }; @@ -123,6 +125,7 @@ const RequestButton = ({ ); onUpdate(); + mutate('/api/v1/request/count'); }; const buttons: ButtonOption[] = []; diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index f909d6171..e936d98e1 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -80,6 +80,7 @@ const RequestCardError = ({ requestData }: RequestCardErrorProps) => { if (!res.ok) throw new Error(); mutate('/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); }; return ( @@ -271,6 +272,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { if (data) { revalidate(); + mutate('/api/v1/request/count'); } }; @@ -280,6 +282,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { }); if (!res.ok) throw new Error(); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); }; const retryRequest = async () => { diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 3d64941ed..5e764ecbb 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -27,7 +27,7 @@ import { useState } from 'react'; import { useInView } from 'react-intersection-observer'; import { FormattedRelativeTime, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; const messages = defineMessages('components.RequestList.RequestItem', { seasons: '{seasonCount, plural, one {Season} other {Seasons}}', @@ -69,6 +69,7 @@ const RequestItemError = ({ }); if (!res.ok) throw new Error(); revalidateList(); + mutate('/api/v1/request/count'); }; const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ @@ -334,6 +335,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { if (data) { revalidate(); + mutate('/api/v1/request/count'); } }; @@ -344,6 +346,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { if (!res.ok) throw new Error(); revalidateList(); + mutate('/api/v1/request/count'); }; const deleteMediaFile = async () => { diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 4dd795241..0f83bea72 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -16,7 +16,7 @@ import type { Collection } from '@server/models/Collection'; import { useCallback, useEffect, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; const messages = defineMessages('components.RequestModal', { requestadmin: 'This request will be approved automatically.', @@ -220,6 +220,7 @@ const CollectionRequestModal = ({ ? MediaStatus.UNKNOWN : MediaStatus.PARTIALLY_AVAILABLE ); + mutate('/api/v1/request/count'); } addToast( @@ -239,7 +240,16 @@ const CollectionRequestModal = ({ } finally { setIsUpdating(false); } - }, [requestOverrides, data, onComplete, addToast, intl, selectedParts, is4k]); + }, [ + requestOverrides, + data?.parts, + data?.name, + onComplete, + addToast, + intl, + selectedParts, + is4k, + ]); const hasAutoApprove = hasPermission( [ diff --git a/src/components/RequestModal/MovieRequestModal.tsx b/src/components/RequestModal/MovieRequestModal.tsx index 85af7aef4..756385861 100644 --- a/src/components/RequestModal/MovieRequestModal.tsx +++ b/src/components/RequestModal/MovieRequestModal.tsx @@ -104,6 +104,7 @@ const MovieRequestModal = ({ if (!res.ok) throw new Error(); const mediaRequest: MediaRequest = await res.json(); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); if (mediaRequest) { if (onComplete) { @@ -138,7 +139,16 @@ const MovieRequestModal = ({ } finally { setIsUpdating(false); } - }, [data, onComplete, addToast, requestOverrides, hasPermission, intl, is4k]); + }, [ + requestOverrides, + data?.id, + data?.title, + is4k, + onComplete, + addToast, + intl, + hasPermission, + ]); const cancelRequest = async () => { setIsUpdating(true); @@ -150,6 +160,7 @@ const MovieRequestModal = ({ if (!res.ok) throw new Error(); mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); if (res.status === 204) { if (onComplete) { @@ -197,6 +208,7 @@ const MovieRequestModal = ({ if (!res.ok) throw new Error(); } mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); addToast( diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index 7480578da..0ef1afd1a 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -106,6 +106,7 @@ const TvRequestModal = ({ if (onUpdating) { onUpdating(true); + mutate('/api/v1/request/count'); } try { @@ -141,6 +142,7 @@ const TvRequestModal = ({ if (!res.ok) throw new Error(); } mutate('/api/v1/request?filter=all&take=10&sort=modified&skip=0'); + mutate('/api/v1/request/count'); addToast( @@ -189,6 +191,7 @@ const TvRequestModal = ({ if (onUpdating) { onUpdating(true); + mutate('/api/v1/request/count'); } try { diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index 82ac68403..d34edb649 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -238,6 +238,10 @@ const NotificationsDiscord = () => { name="botUsername" type="text" placeholder={settings.currentSettings.applicationTitle} + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" />
{errors.botUsername && diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index fdb292d32..6daff08d7 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -104,7 +104,7 @@ const NotificationsEmail = () => { otherwise: Yup.string().nullable(), }) .matches( - /-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/s, + /-----BEGIN PGP PRIVATE KEY BLOCK-----.+-----END PGP PRIVATE KEY BLOCK-----/, intl.formatMessage(messages.validationPgpPrivateKey) ), pgpPassword: Yup.string().when('pgpPrivateKey', { @@ -295,6 +295,10 @@ const NotificationsEmail = () => { name="emailFrom" type="text" inputMode="email" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" />
{errors.emailFrom && @@ -316,6 +320,10 @@ const NotificationsEmail = () => { name="smtpHost" type="text" inputMode="url" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" />
{errors.smtpHost && @@ -337,6 +345,10 @@ const NotificationsEmail = () => { type="text" inputMode="numeric" className="short" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.smtpPort && touched.smtpPort && @@ -390,7 +402,15 @@ const NotificationsEmail = () => {
- +
@@ -400,12 +420,7 @@ const NotificationsEmail = () => {
- +
@@ -430,6 +445,10 @@ const NotificationsEmail = () => { type="textarea" rows="10" className="font-mono text-xs" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.pgpPrivateKey && @@ -457,7 +476,10 @@ const NotificationsEmail = () => { as="field" id="pgpPassword" name="pgpPassword" - autoComplete="one-time-code" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.pgpPassword && diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index 6636c6b4c..bfac39fea 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -245,7 +245,7 @@ const NotificationsTelegram = () => { as="field" id="botAPI" name="botAPI" - autoComplete="one-time-code" + type="text" /> {errors.botAPI && @@ -264,7 +264,15 @@ const NotificationsTelegram = () => {
- +
{errors.botUsername && touched.botUsername && @@ -294,7 +302,15 @@ const NotificationsTelegram = () => {
- +
{errors.chatId && touched.chatId && diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index fbeb2decd..b1ae42f7f 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -382,6 +382,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { id="name" name="name" type="text" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" onChange={(e: React.ChangeEvent) => { setIsValidated(false); setFieldValue('name', e.target.value); @@ -475,7 +479,6 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => { as="field" id="apiKey" name="apiKey" - autoComplete="one-time-code" onChange={(e: React.ChangeEvent) => { setIsValidated(false); setFieldValue('apiKey', e.target.value); diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 780dc0eef..2a2c6167d 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -872,6 +872,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { id="tautulliPort" name="tautulliPort" className="short" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.tautulliPort && touched.tautulliPort && @@ -909,6 +913,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { inputMode="url" id="tautulliUrlBase" name="tautulliUrlBase" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" />
{errors.tautulliUrlBase && @@ -929,7 +937,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { as="field" id="tautulliApiKey" name="tautulliApiKey" - autoComplete="one-time-code" />
{errors.tautulliApiKey && @@ -950,6 +957,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => { inputMode="url" id="tautulliExternalUrl" name="tautulliExternalUrl" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.tautulliExternalUrl && diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index fc058c0b0..65180cac9 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -119,6 +119,8 @@ const ServerInstance = ({

{name} @@ -147,6 +149,8 @@ const ServerInstance = ({ {internalUrl} @@ -159,7 +163,12 @@ const ServerInstance = ({ {profileName}

-
+ {isSonarr ? ( ) : ( diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index fedea2a65..0728c54d8 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -415,6 +415,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { id="name" name="name" type="text" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" onChange={(e: React.ChangeEvent) => { setIsValidated(false); setFieldValue('name', e.target.value); @@ -508,7 +512,6 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => { as="field" id="apiKey" name="apiKey" - autoComplete="one-time-code" onChange={(e: React.ChangeEvent) => { setIsValidated(false); setFieldValue('apiKey', e.target.value); diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index bc7c44414..ae76c6ad0 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -399,6 +399,10 @@ const UserList = () => { name="email" type="text" inputMode="email" + autoComplete="off" + data-1pignore="true" + data-lpignore="true" + data-bwignore="true" /> {errors.email && diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 3ab8ab137..9e87cbdf0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -11,6 +11,7 @@ import { LanguageContext } from '@app/context/LanguageContext'; import { SettingsProvider } from '@app/context/SettingsContext'; import { UserContext } from '@app/context/UserContext'; import type { User } from '@app/hooks/useUser'; +import { Permission, useUser } from '@app/hooks/useUser'; import '@app/styles/globals.css'; import '@app/utils/fetchOverride'; import { polyfillIntl } from '@app/utils/polyfillIntl'; @@ -128,6 +129,35 @@ const CoreApp: Omit = ({ loadLocaleData(currentLocale).then(setMessages); }, [currentLocale]); + const { hasPermission } = useUser(); + + useEffect(() => { + const requestsCount = async () => { + const response = await fetch('/api/v1/request/count'); + return await response.json(); + }; + + // Cast navigator to a type that includes setAppBadge and clearAppBadge + // to avoid TypeScript errors while ensuring these methods exist before calling them. + const newNavigator = navigator as unknown as { + setAppBadge?: (count: number) => Promise; + clearAppBadge?: () => Promise; + }; + + if ('setAppBadge' in navigator) { + if ( + !router.pathname.match(/(login|setup|resetpassword)/) && + hasPermission(Permission.ADMIN) + ) { + requestsCount().then((data) => + newNavigator?.setAppBadge?.(data.pending) + ); + } else { + newNavigator?.clearAppBadge?.(); + } + } + }, [hasPermission, router.pathname]); + if (router.pathname.match(/(login|setup|resetpassword)/)) { component = ; } else {