From 6c1742e94ccfc6c13cf1d25fd9e893ee1f431aae Mon Sep 17 00:00:00 2001 From: sct Date: Wed, 20 Jan 2021 18:56:45 +0900 Subject: [PATCH] feat(frontend): add option to hide all available items from discovery (#699) --- overseerr-api.yml | 3 ++ server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/settings.ts | 4 +++ src/components/Discover/DiscoverMovies.tsx | 14 +++++++- src/components/Discover/DiscoverTv.tsx | 16 ++++++++- src/components/Discover/Trending.tsx | 14 +++++++- src/components/Discover/Upcoming.tsx | 13 ++++++- src/components/MediaSlider/index.tsx | 36 +++++++++++++++---- .../MovieDetails/MovieRecommendations.tsx | 13 ++++++- src/components/MovieDetails/MovieSimilar.tsx | 13 ++++++- src/components/RequestButton/index.tsx | 6 ++-- src/components/Settings/SettingsMain.tsx | 29 +++++++++++++++ .../TvDetails/TvRecommendations.tsx | 16 ++++++++- src/components/TvDetails/TvSimilar.tsx | 13 ++++++- src/context/SettingsContext.tsx | 3 +- src/hooks/useSettings.ts | 13 +++++++ src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 2 ++ src/pages/_app.tsx | 1 + 19 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 src/hooks/useSettings.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index d77b596bd..32e505602 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -62,6 +62,9 @@ components: applicationUrl: type: string example: https://os.example.com + hideAvailable: + type: boolean + example: false defaultPermissions: type: number example: 32 diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index eba40ee29..52be4c4c8 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -9,4 +9,5 @@ export interface PublicSettingsResponse { initialized: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; + hideAvailable: boolean; } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index b9ad92a9f..42abe877c 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -49,6 +49,7 @@ export interface MainSettings { apiKey: string; applicationUrl: string; defaultPermissions: number; + hideAvailable: boolean; } interface PublicSettings { @@ -58,6 +59,7 @@ interface PublicSettings { interface FullPublicSettings extends PublicSettings { movie4kEnabled: boolean; series4kEnabled: boolean; + hideAvailable: boolean; } export interface NotificationAgentConfig { @@ -150,6 +152,7 @@ class Settings { main: { apiKey: '', applicationUrl: '', + hideAvailable: false, defaultPermissions: Permission.REQUEST, }, plex: { @@ -281,6 +284,7 @@ class Settings { series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), + hideAvailable: this.data.main.hideAvailable, }; } diff --git a/src/components/Discover/DiscoverMovies.tsx b/src/components/Discover/DiscoverMovies.tsx index fc8a50968..8491aa304 100644 --- a/src/components/Discover/DiscoverMovies.tsx +++ b/src/components/Discover/DiscoverMovies.tsx @@ -5,6 +5,8 @@ import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, FormattedMessage } from 'react-intl'; import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ discovermovies: 'Popular Movies', @@ -18,6 +20,7 @@ interface SearchResult { } const DiscoverMovies: React.FC = () => { + const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { @@ -45,11 +48,20 @@ const DiscoverMovies: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/Discover/DiscoverTv.tsx b/src/components/Discover/DiscoverTv.tsx index 7b4fb012f..abd74d5a4 100644 --- a/src/components/Discover/DiscoverTv.tsx +++ b/src/components/Discover/DiscoverTv.tsx @@ -5,6 +5,8 @@ import ListView from '../Common/ListView'; import { defineMessages, FormattedMessage } from 'react-intl'; import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ discovertv: 'Popular Series', @@ -18,6 +20,7 @@ interface SearchResult { } const DiscoverTv: React.FC = () => { + const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { @@ -45,7 +48,18 @@ const DiscoverTv: React.FC = () => { return
{error}
; } - const titles = data?.reduce((a, v) => [...a, ...v.results], [] as TvResult[]); + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as TvResult[] + ); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index 24f4755e5..e38673da5 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -9,6 +9,8 @@ import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, FormattedMessage } from 'react-intl'; import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ trending: 'Trending', @@ -22,6 +24,7 @@ interface SearchResult { } const Trending: React.FC = () => { + const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { @@ -51,11 +54,20 @@ const Trending: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as (MovieResult | TvResult | PersonResult)[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index 1687d0bc2..abfce2dc2 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -5,6 +5,8 @@ import ListView from '../Common/ListView'; import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, FormattedMessage } from 'react-intl'; import Header from '../Common/Header'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ upcomingmovies: 'Upcoming Movies', @@ -18,6 +20,7 @@ interface SearchResult { } const UpcomingMovies: React.FC = () => { + const settings = useSettings(); const { locale } = useContext(LanguageContext); const { data, error, size, setSize } = useSWRInfinite( (pageIndex: number, previousPageData: SearchResult | null) => { @@ -47,11 +50,19 @@ const UpcomingMovies: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 5df3f490c..0758a886d 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -1,12 +1,14 @@ import Link from 'next/link'; -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { useSWRInfinite } from 'swr'; +import { MediaStatus } from '../../../server/constants/media'; import type { MovieResult, PersonResult, TvResult, } from '../../../server/models/Search'; import { LanguageContext } from '../../context/LanguageContext'; +import useSettings from '../../hooks/useSettings'; import PersonCard from '../PersonCard'; import Slider from '../Slider'; import TitleCard from '../TitleCard'; @@ -34,8 +36,9 @@ const MediaSlider: React.FC = ({ sliderKey, hideWhenEmpty = false, }) => { + const settings = useSettings(); const { locale } = useContext(LanguageContext); - const { data, error } = useSWRInfinite( + const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { return null; @@ -48,15 +51,34 @@ const MediaSlider: React.FC = ({ } ); - if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { - return null; - } - - const titles = (data ?? []).reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as (MovieResult | TvResult | PersonResult)[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + (i.mediaType === 'movie' || i.mediaType === 'tv') && + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + + useEffect(() => { + if ( + titles.length < 24 && + size < 5 && + (data?.[0]?.totalResults ?? 0) > size * 20 + ) { + setSize(size + 1); + } + }, [titles, setSize, size, data]); + + if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { + return null; + } + const finalTitles = titles.slice(0, 20).map((title) => { switch (title.mediaType) { case 'movie': diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index a9a811024..382d45b25 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -7,6 +7,8 @@ import Header from '../Common/Header'; import type { MovieDetails } from '../../../server/models/Movie'; import { LanguageContext } from '../../context/LanguageContext'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import useSettings from '../../hooks/useSettings'; +import { MediaStatus } from '../../../server/constants/media'; const messages = defineMessages({ recommendations: 'Recommendations', @@ -21,6 +23,7 @@ interface SearchResult { } const MovieRecommendations: React.FC = () => { + const settings = useSettings(); const intl = useIntl(); const router = useRouter(); const { locale } = useContext(LanguageContext); @@ -55,11 +58,19 @@ const MovieRecommendations: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index 7e6d6fd7b..b8017be71 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -7,6 +7,8 @@ import Header from '../Common/Header'; import { LanguageContext } from '../../context/LanguageContext'; import type { MovieDetails } from '../../../server/models/Movie'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; +import { MediaStatus } from '../../../server/constants/media'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ similar: 'Similar Titles', @@ -21,6 +23,7 @@ interface SearchResult { } const MovieSimilar: React.FC = () => { + const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); @@ -55,11 +58,19 @@ const MovieSimilar: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 9c0a33d83..0f3ad6da1 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -1,5 +1,5 @@ import axios from 'axios'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { MediaRequestStatus, @@ -7,7 +7,7 @@ import { } from '../../../server/constants/media'; import Media from '../../../server/entity/Media'; import { MediaRequest } from '../../../server/entity/MediaRequest'; -import { SettingsContext } from '../../context/SettingsContext'; +import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import RequestModal from '../RequestModal'; @@ -58,7 +58,7 @@ const RequestButton: React.FC = ({ is4kShowComplete = false, }) => { const intl = useIntl(); - const settings = useContext(SettingsContext); + const settings = useSettings(); const { hasPermission } = useUser(); const [showRequestModal, setShowRequestModal] = useState(false); const [showRequest4kModal, setShowRequest4kModal] = useState(false); diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index 41a754244..59254b68b 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -11,6 +11,8 @@ import { useUser, Permission } from '../../hooks/useUser'; import { useToasts } from 'react-toast-notifications'; import { messages as permissionMessages } from '../UserEdit'; import PermissionOption, { PermissionItem } from '../PermissionOption'; +import Badge from '../Common/Badge'; +import globalMessages from '../../i18n/globalMessages'; const messages = defineMessages({ generalsettings: 'General Settings', @@ -25,6 +27,7 @@ const messages = defineMessages({ toastSettingsSuccess: 'Settings saved.', toastSettingsFailure: 'Something went wrong saving settings.', defaultPermissions: 'Default User Permissions', + hideAvailable: 'Hide available media', }); const SettingsMain: React.FC = () => { @@ -166,6 +169,7 @@ const SettingsMain: React.FC = () => { initialValues={{ applicationUrl: data?.applicationUrl, defaultPermissions: data?.defaultPermissions ?? 0, + hideAvailable: data?.hideAvailable, }} enableReinitialize onSubmit={async (values) => { @@ -173,6 +177,7 @@ const SettingsMain: React.FC = () => { await axios.post('/api/v1/settings/main', { applicationUrl: values.applicationUrl, defaultPermissions: values.defaultPermissions, + hideAvailable: values.hideAvailable, }); addToast(intl.formatMessage(messages.toastSettingsSuccess), { @@ -256,6 +261,30 @@ const SettingsMain: React.FC = () => { +
+ +
+ { + setFieldValue('hideAvailable', !values.hideAvailable); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
diff --git a/src/components/TvDetails/TvRecommendations.tsx b/src/components/TvDetails/TvRecommendations.tsx index c27e97b58..25c6a0894 100644 --- a/src/components/TvDetails/TvRecommendations.tsx +++ b/src/components/TvDetails/TvRecommendations.tsx @@ -7,6 +7,8 @@ import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { TvDetails } from '../../../server/models/Tv'; +import { MediaStatus } from '../../../server/constants/media'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ recommendations: 'Recommendations', @@ -21,6 +23,7 @@ interface SearchResult { } const TvRecommendations: React.FC = () => { + const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); @@ -55,7 +58,18 @@ const TvRecommendations: React.FC = () => { return
{error}
; } - const titles = data?.reduce((a, v) => [...a, ...v.results], [] as TvResult[]); + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as TvResult[] + ); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = diff --git a/src/components/TvDetails/TvSimilar.tsx b/src/components/TvDetails/TvSimilar.tsx index fa8122620..c0c4c05dd 100644 --- a/src/components/TvDetails/TvSimilar.tsx +++ b/src/components/TvDetails/TvSimilar.tsx @@ -7,6 +7,8 @@ import { LanguageContext } from '../../context/LanguageContext'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import type { TvDetails } from '../../../server/models/Tv'; import Header from '../Common/Header'; +import { MediaStatus } from '../../../server/constants/media'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ similar: 'Similar Series', @@ -21,6 +23,7 @@ interface SearchResult { } const TvSimilar: React.FC = () => { + const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); @@ -55,11 +58,19 @@ const TvSimilar: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 183555456..ef12affa5 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces'; import useSWR from 'swr'; -interface SettingsContextProps { +export interface SettingsContextProps { currentSettings: PublicSettingsResponse; } @@ -10,6 +10,7 @@ const defaultSettings = { initialized: false, movie4kEnabled: false, series4kEnabled: false, + hideAvailable: false, }; export const SettingsContext = React.createContext({ diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 000000000..0fb7d7e9e --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { + SettingsContext, + SettingsContextProps, +} from '../context/SettingsContext'; + +const useSettings = (): SettingsContextProps => { + const settings = useContext(SettingsContext); + + return settings; +}; + +export default useSettings; diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 6daeb698d..810ba0cac 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -21,6 +21,7 @@ const globalMessages = defineMessages({ deleting: 'Deleting…', close: 'Close', edit: 'Edit', + experimental: 'Experimental', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d5c3d3308..6cb399f71 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -351,6 +351,7 @@ "components.Settings.edit": "Edit", "components.Settings.generalsettings": "General Settings", "components.Settings.generalsettingsDescription": "These are settings related to general Overseerr configuration.", + "components.Settings.hideAvailable": "Hide available media", "components.Settings.hostname": "Hostname/IP", "components.Settings.jobname": "Job Name", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", @@ -517,6 +518,7 @@ "i18n.delete": "Delete", "i18n.deleting": "Deleting…", "i18n.edit": "Edit", + "i18n.experimental": "Experimental", "i18n.failed": "Failed", "i18n.movies": "Movies", "i18n.partiallyavailable": "Partially Available", diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5af86ac63..bf6b279b0 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -135,6 +135,7 @@ CoreApp.getInitialProps = async (initialProps) => { let user = undefined; let currentSettings: PublicSettingsResponse = { initialized: false, + hideAvailable: false, movie4kEnabled: false, series4kEnabled: false, };