diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index e37eccc948..0109e9a9fe 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -121,6 +121,22 @@ export interface ProxySettings { bypassLocalAddresses: boolean; } +interface FilterSettings { + filterSearch: boolean; + filterTrending: boolean; + filterSimilarSeries: boolean; + filterTvRecommendations: boolean; + filterSimilarMovies: boolean; + filterMovieRecommendations: boolean; + filterTvUpcoming: boolean; + filterUpcomingMovies: boolean; + filterDiscoverMovies: boolean; + filterTvDiscover: boolean; + filterPopularMovies: boolean; + filterTvPopular: boolean; + filterCustomSliders: boolean; +} + export interface MainSettings { apiKey: string; applicationTitle: string; @@ -146,6 +162,7 @@ export interface MainSettings { enableSpecialEpisodes: boolean; locale: string; youtubeUrl: string; + filters: FilterSettings; } export interface ProxySettings { @@ -177,7 +194,7 @@ interface PublicSettings { initialized: boolean; } -interface FullPublicSettings extends PublicSettings { +export interface FullPublicSettings extends PublicSettings { applicationTitle: string; applicationUrl: string; hideAvailable: boolean; @@ -203,6 +220,7 @@ interface FullPublicSettings extends PublicSettings { userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; + filters: FilterSettings; } export interface NotificationAgentConfig { @@ -403,6 +421,21 @@ class Settings { enableSpecialEpisodes: false, locale: 'en', youtubeUrl: '', + filters: { + filterSearch: false, + filterTrending: false, + filterSimilarSeries: false, + filterTvRecommendations: false, + filterSimilarMovies: false, + filterMovieRecommendations: false, + filterTvUpcoming: false, + filterUpcomingMovies: false, + filterDiscoverMovies: false, + filterTvDiscover: false, + filterPopularMovies: false, + filterTvPopular: false, + filterCustomSliders: false, + }, }, plex: { name: '', @@ -697,6 +730,7 @@ class Settings { this.data.notifications.agents.email.options.userEmailRequired, newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, + filters: this.data.main.filters, }; } diff --git a/src/components/Discover/DiscoverMovieKeyword/index.tsx b/src/components/Discover/DiscoverMovieKeyword/index.tsx index 830b95df03..f71beef5cb 100644 --- a/src/components/Discover/DiscoverMovieKeyword/index.tsx +++ b/src/components/Discover/DiscoverMovieKeyword/index.tsx @@ -2,8 +2,10 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { MovieResult } from '@server/models/Search'; @@ -34,6 +36,13 @@ const DiscoverMovieKeyword = () => { } ); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.DISCOVER_MOVIES, + }); + if (error) { return ; } @@ -53,10 +62,11 @@ const DiscoverMovieKeyword = () => {
{title}
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/Discover/DiscoverMovies/index.tsx b/src/components/Discover/DiscoverMovies/index.tsx index 4f67c20505..c2d71bfb12 100644 --- a/src/components/Discover/DiscoverMovies/index.tsx +++ b/src/components/Discover/DiscoverMovies/index.tsx @@ -9,8 +9,10 @@ import { } from '@app/components/Discover/constants'; import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; @@ -65,6 +67,13 @@ const DiscoverMovies = () => { ); const [showFilters, setShowFilters] = useState(false); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.DISCOVER_MOVIES, + }); + if (error) { return ; } @@ -133,10 +142,11 @@ const DiscoverMovies = () => { 0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/Discover/DiscoverTv/index.tsx b/src/components/Discover/DiscoverTv/index.tsx index 9d11eeffda..b429a63b07 100644 --- a/src/components/Discover/DiscoverTv/index.tsx +++ b/src/components/Discover/DiscoverTv/index.tsx @@ -9,8 +9,10 @@ import { } from '@app/components/Discover/constants'; import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; @@ -63,6 +65,13 @@ const DiscoverTv = () => { ...preparedFilters, }); + const filteredTitles = useFilterByLanguages({ + titles, + movie: false, + tv: true, + key: FilterByLanguage.TV_DISCOVER, + }); + if (error) { return ; } @@ -131,11 +140,12 @@ const DiscoverTv = () => { 0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } onScrollBottom={fetchMore} /> diff --git a/src/components/Discover/DiscoverTvKeyword/index.tsx b/src/components/Discover/DiscoverTvKeyword/index.tsx index a9719fd2d8..8ab0e9c29e 100644 --- a/src/components/Discover/DiscoverTvKeyword/index.tsx +++ b/src/components/Discover/DiscoverTvKeyword/index.tsx @@ -2,8 +2,10 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import type { TvResult } from '@server/models/Search'; @@ -34,6 +36,13 @@ const DiscoverTvKeyword = () => { } ); + const filteredTitles = useFilterByLanguages({ + titles, + movie: false, + tv: true, + key: FilterByLanguage.TV_DISCOVER, + }); + if (error) { return ; } @@ -53,10 +62,11 @@ const DiscoverTvKeyword = () => {
{title}
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/Discover/DiscoverTvUpcoming.tsx b/src/components/Discover/DiscoverTvUpcoming.tsx index a6a3be170c..3cccfda143 100644 --- a/src/components/Discover/DiscoverTvUpcoming.tsx +++ b/src/components/Discover/DiscoverTvUpcoming.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { TvResult } from '@server/models/Search'; import { useIntl } from 'react-intl'; @@ -24,6 +26,13 @@ const DiscoverTvUpcoming = () => { error, } = useDiscover('/api/v1/discover/tv/upcoming'); + const filteredTitles = useFilterByLanguages({ + titles, + movie: false, + tv: true, + key: FilterByLanguage.TV_UPCOMING, + }); + if (error) { return ; } @@ -35,11 +44,12 @@ const DiscoverTvUpcoming = () => {
{intl.formatMessage(messages.upcomingtv)}
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } onScrollBottom={fetchMore} /> diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index abdd010501..67d3029685 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -205,6 +205,7 @@ const FilterSlideover = ({ setFieldValue={(_key, value) => { updateQueryParams('language', value); }} + isDisabled={!!currentSettings.originalLanguage} /> {intl.formatMessage(messages.certification)} diff --git a/src/components/Discover/Trending.tsx b/src/components/Discover/Trending.tsx index fc6e90902a..9dfba358cb 100644 --- a/src/components/Discover/Trending.tsx +++ b/src/components/Discover/Trending.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { MovieResult, @@ -29,6 +31,13 @@ const Trending = () => { '/api/v1/discover/trending' ); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: true, + key: FilterByLanguage.TRENDING, + }); + if (error) { return ; } @@ -40,10 +49,11 @@ const Trending = () => {
{intl.formatMessage(messages.trending)}
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/Discover/Upcoming.tsx b/src/components/Discover/Upcoming.tsx index a358e995bd..b084d9b4c9 100644 --- a/src/components/Discover/Upcoming.tsx +++ b/src/components/Discover/Upcoming.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { MovieResult } from '@server/models/Search'; import { useIntl } from 'react-intl'; @@ -24,6 +26,13 @@ const UpcomingMovies = () => { error, } = useDiscover('/api/v1/discover/movies/upcoming'); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.UPCOMING_MOVIES, + }); + if (error) { return ; } @@ -35,10 +44,11 @@ const UpcomingMovies = () => {
{intl.formatMessage(messages.upcomingmovies)}
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index b3042b861a..96019535b0 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -2,8 +2,10 @@ import ShowMoreCard from '@app/components/MediaSlider/ShowMoreCard'; import PersonCard from '@app/components/PersonCard'; import Slider from '@app/components/Slider'; import TitleCard from '@app/components/TitleCard'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import useSettings from '@app/hooks/useSettings'; import { useUser } from '@app/hooks/useUser'; +import { FilterByLanguage } from '@app/types/filters'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import { Permission } from '@server/lib/permissions'; @@ -44,137 +46,197 @@ const MediaSlider = ({ }: MediaSliderProps) => { const settings = useSettings(); const { hasPermission } = useUser(); + + let isSeries = !sliderKey ? url.includes('tv') : true; + let isMovies = !sliderKey ? url.includes('movie') : true; + const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { - if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { + if (previousPageData && pageIndex + 1 > previousPageData.totalPages) return null; - } - return `${url}?page=${pageIndex + 1}${ extraParams ? `&${extraParams}` : '' }`; }, - { - initialSize: 2, - revalidateFirstPage: false, - } + { initialSize: 2, revalidateFirstPage: false } ); + // Combine all results let titles = (data ?? []).reduce( - (a, v) => [...a, ...v.results], + (acc, page) => [...acc, ...page.results], [] as (MovieResult | TvResult | PersonResult)[] ); + // Apply app-level settings filters 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 + (t) => + (t.mediaType === 'movie' || t.mediaType === 'tv') && + t.mediaInfo?.status !== MediaStatus.AVAILABLE && + t.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE ); } if (settings.currentSettings.hideBlacklisted) { titles = titles.filter( - (i) => - (i.mediaType === 'movie' || i.mediaType === 'tv') && - i.mediaInfo?.status !== MediaStatus.BLACKLISTED + (t) => + (t.mediaType === 'movie' || t.mediaType === 'tv') && + t.mediaInfo?.status !== MediaStatus.BLACKLISTED ); } - useEffect(() => { - if ( - titles.length < 24 && - size < 5 && - (data?.[0]?.totalResults ?? 0) > size * 20 - ) { - setSize(size + 1); + const getKey = () => { + if (sliderKey === 'recommendations') { + return isMovies + ? FilterByLanguage.MOVIE_RECOMMENDATIONS + : FilterByLanguage.TV_RECOMMENDATIONS; } - if (onNewTitles) { - // We aren't reporting all titles. We just want to know if there are any titles - // at all for our purposes. - onNewTitles(titles.length); + if (sliderKey === 'similar') { + return isMovies + ? FilterByLanguage.SIMILAR_MOVIES + : FilterByLanguage.SIMILAR_SERIES; } - }, [titles, setSize, size, data, onNewTitles]); - if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) { - return null; - } + if (sliderKey === 'trending') { + isMovies = true; + isSeries = true; + return FilterByLanguage.TRENDING; + } + + if (sliderKey === 'popular-movies') { + isMovies = true; + isSeries = false; + return FilterByLanguage.POPULAR_MOVIES; + } + + if (sliderKey === 'upcoming') { + isMovies = true; + isSeries = false; + return FilterByLanguage.UPCOMING_MOVIES; + } + + if (sliderKey === 'popular-tv') { + isMovies = false; + isSeries = true; + return FilterByLanguage.TV_POPULAR; + } + + if (sliderKey === 'upcoming-tv') { + isMovies = false; + isSeries = true; + return FilterByLanguage.TV_UPCOMING; + } + + if (sliderKey === 'custom') { + isMovies = true; + isSeries = true; + return FilterByLanguage.CUSTOM_SLIDERS; + } - const blacklistVisibility = hasPermission( + return undefined; + }; + + // Filter by original languages dynamically using our hook + const filteredTitles = useFilterByLanguages({ + titles, + movie: isMovies, + tv: isSeries, + key: getKey(), + }); + + // Blacklist visibility + const blacklistVisible = hasPermission( [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { type: 'or' } ); - const finalTitles = titles + // Map filtered titles to JSX cards + const finalTitles = filteredTitles .slice(0, 20) - .filter((title) => { - if (!blacklistVisibility) - return ( - (title as TvResult | MovieResult).mediaInfo?.status !== - MediaStatus.BLACKLISTED - ); - return title; + .filter((t) => { + if ( + (t.mediaType === 'movie' || t.mediaType === 'tv') && + !blacklistVisible + ) { + return t.mediaInfo?.status !== MediaStatus.BLACKLISTED; + } + return true; // person results untouched }) - .map((title) => { - switch (title.mediaType) { + .map((t) => { + switch (t.mediaType) { case 'movie': return ( 0} + key={t.id} + id={t.id} + isAddedToWatchlist={t.mediaInfo?.watchlists?.length ?? 0} + image={t.posterPath} + status={t.mediaInfo?.status} + summary={t.overview} + title={t.title} + userScore={t.voteAverage} + year={t.releaseDate} + mediaType={t.mediaType} + inProgress={(t.mediaInfo?.downloadStatus ?? []).length > 0} /> ); case 'tv': return ( 0} + key={t.id} + id={t.id} + isAddedToWatchlist={t.mediaInfo?.watchlists?.length ?? 0} + image={t.posterPath} + status={t.mediaInfo?.status} + summary={t.overview} + title={t.name} + userScore={t.voteAverage} + year={t.firstAirDate} + mediaType={t.mediaType} + inProgress={(t.mediaInfo?.downloadStatus ?? []).length > 0} /> ); case 'person': return ( ); } }); - if (linkUrl && titles.length > 20) { + // Optionally add "Show More" card + if (linkUrl && filteredTitles.length > 20) { finalTitles.push( - title.mediaType !== 'person' ? title.posterPath : undefined - )} + .map((t) => (t.mediaType !== 'person' ? t.posterPath : undefined))} /> ); } + // Auto-fetch more if fewer than 24 titles + useEffect(() => { + if ( + filteredTitles.length < 24 && + size < 5 && + (data?.[0]?.totalResults ?? 0) > size * 20 + ) { + setSize(size + 1); + } + + if (onNewTitles) onNewTitles(filteredTitles.length); + }, [filteredTitles, size, setSize, data, onNewTitles]); + + if (hideWhenEmpty && filteredTitles.length === 0) return null; + return ( <>
diff --git a/src/components/MovieDetails/MovieRecommendations.tsx b/src/components/MovieDetails/MovieRecommendations.tsx index 4cbd607b99..162eae1554 100644 --- a/src/components/MovieDetails/MovieRecommendations.tsx +++ b/src/components/MovieDetails/MovieRecommendations.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { MovieDetails } from '@server/models/Movie'; import type { MovieResult } from '@server/models/Search'; @@ -33,6 +35,13 @@ const MovieRecommendations = () => { `/api/v1/movie/${router.query.movieId}/recommendations` ); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.MOVIE_RECOMMENDATIONS, + }); + if (error) { return ; } @@ -54,11 +63,12 @@ const MovieRecommendations = () => {
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } onScrollBottom={fetchMore} /> diff --git a/src/components/MovieDetails/MovieSimilar.tsx b/src/components/MovieDetails/MovieSimilar.tsx index fbb2274ac8..1068399c26 100644 --- a/src/components/MovieDetails/MovieSimilar.tsx +++ b/src/components/MovieDetails/MovieSimilar.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { MovieDetails } from '@server/models/Movie'; import type { MovieResult } from '@server/models/Search'; @@ -31,6 +33,13 @@ const MovieSimilar = () => { error, } = useDiscover(`/api/v1/movie/${router.query.movieId}/similar`); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.SIMILAR_MOVIES, + }); + if (error) { return ; } @@ -52,10 +61,11 @@ const MovieSimilar = () => { 0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } isReachingEnd={isReachingEnd} onScrollBottom={fetchMore} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a0fae3864b..6842bdc930 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { MovieResult, @@ -37,6 +39,13 @@ const Search = () => { { hideAvailable: false, hideBlacklisted: false } ); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: true, + key: FilterByLanguage.SEARCH, + }); + if (error) { return ; } @@ -48,7 +57,7 @@ const Search = () => {
{intl.formatMessage(messages.searchresults)}
0) diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 7de8f83785..1e90e5a69c 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -6,11 +6,13 @@ import SensitiveInput from '@app/components/Common/SensitiveInput'; import LanguageSelector from '@app/components/LanguageSelector'; import RegionSelector from '@app/components/RegionSelector'; import CopyButton from '@app/components/Settings/CopyButton'; +import LibraryItem from '@app/components/Settings/LibraryItem'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; import { availableLanguages } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; +import type { FilterItem } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import { isValidURL } from '@app/utils/urlValidationHelper'; import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; @@ -72,6 +74,22 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationUrlTrailingSlash: 'URL must not end in a trailing slash', }); +const filters: FilterItem[] = [ + { key: 'filterSearch', name: 'Search' }, + { key: 'filterTrending', name: 'Trending' }, + { key: 'filterSimilarSeries', name: 'Similar Series' }, + { key: 'filterSimilarMovies', name: 'Similar Movies' }, + { key: 'filterTvRecommendations', name: 'TV Recommendations' }, + { key: 'filterMovieRecommendations', name: 'Movie Recommendations' }, + { key: 'filterTvUpcoming', name: 'Upcoming Series' }, + { key: 'filterUpcomingMovies', name: 'Upcoming Movies' }, + { key: 'filterDiscoverMovies', name: 'Discover Movies' }, + { key: 'filterTvDiscover', name: 'Discover Series' }, + { key: 'filterPopularMovies', name: 'Popular Movies' }, + { key: 'filterTvPopular', name: 'Popular Series' }, + { key: 'filterCustomSliders', name: 'Custom Sliders' }, +]; + const SettingsMain = () => { const { addToast } = useToasts(); const { user: currentUser, hasPermission: userHasPermission } = useUser(); @@ -175,6 +193,20 @@ const SettingsMain = () => { enableSpecialEpisodes: data?.enableSpecialEpisodes, cacheImages: data?.cacheImages, youtubeUrl: data?.youtubeUrl, + filterSearch: data?.filters?.filterSearch, + filterTrending: data?.filters?.filterTrending, + filterSimilarSeries: data?.filters?.filterSimilarSeries, + filterTvRecommendations: data?.filters?.filterTvRecommendations, + filterSimilarMovies: data?.filters?.filterSimilarMovies, + filterMovieRecommendations: + data?.filters?.filterMovieRecommendations, + filterTvUpcoming: data?.filters?.filterTvUpcoming, + filterUpcomingMovies: data?.filters?.filterUpcomingMovies, + filterDiscoverMovies: data?.filters?.filterDiscoverMovies, + filterTvDiscover: data?.filters?.filterTvDiscover, + filterPopularMovies: data?.filters?.filterPopularMovies, + filterTvPopular: data?.filters?.filterTvPopular, + filterCustomSliders: data?.filters?.filterCustomSliders, }} enableReinitialize validationSchema={MainSettingsSchema} @@ -195,6 +227,21 @@ const SettingsMain = () => { enableSpecialEpisodes: values.enableSpecialEpisodes, cacheImages: values.cacheImages, youtubeUrl: values.youtubeUrl, + filters: { + filterSearch: values.filterSearch, + filterTrending: values.filterTrending, + filterSimilarSeries: values.filterSimilarSeries, + filterTvRecommendations: values.filterTvRecommendations, + filterSimilarMovies: values.filterSimilarMovies, + filterMovieRecommendations: values.filterMovieRecommendations, + filterTvUpcoming: values.filterTvUpcoming, + filterUpcomingMovies: values.filterUpcomingMovies, + filterDiscoverMovies: values.filterDiscoverMovies, + filterTvDiscover: values.filterTvDiscover, + filterPopularMovies: values.filterPopularMovies, + filterTvPopular: values.filterTvPopular, + filterCustomSliders: values.filterCustomSliders, + }, }); mutate('/api/v1/settings/public'); mutate('/api/v1/status'); @@ -383,6 +430,31 @@ const SettingsMain = () => { +
+ + Filter by Original Language + + Filter content by their original language. This will take + the values from the Discover Language setting. + + + +
    + {filters.map(({ key, name }) => ( + setFieldValue(key, !values[key])} + /> + ))} +
+
+
0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } onScrollBottom={fetchMore} /> diff --git a/src/components/TvDetails/TvSimilar.tsx b/src/components/TvDetails/TvSimilar.tsx index c6afd9b3c9..db68687ca8 100644 --- a/src/components/TvDetails/TvSimilar.tsx +++ b/src/components/TvDetails/TvSimilar.tsx @@ -2,7 +2,9 @@ import Header from '@app/components/Common/Header'; import ListView from '@app/components/Common/ListView'; import PageTitle from '@app/components/Common/PageTitle'; import useDiscover from '@app/hooks/useDiscover'; +import useFilterByLanguages from '@app/hooks/useFilterByLanguages'; import Error from '@app/pages/_error'; +import { FilterByLanguage } from '@app/types/filters'; import defineMessages from '@app/utils/defineMessages'; import type { TvResult } from '@server/models/Search'; import type { TvDetails } from '@server/models/Tv'; @@ -29,6 +31,13 @@ const TvSimilar = () => { error, } = useDiscover(`/api/v1/tv/${router.query.tvId}/similar`); + const filteredTitles = useFilterByLanguages({ + titles, + movie: true, + tv: false, + key: FilterByLanguage.SIMILAR_SERIES, + }); + if (error) { return ; } @@ -48,11 +57,12 @@ const TvSimilar = () => { 0) + isLoadingInitialData || + (isLoadingMore && (filteredTitles?.length ?? 0) > 0) } onScrollBottom={fetchMore} /> diff --git a/src/hooks/useFilterByLanguages.ts b/src/hooks/useFilterByLanguages.ts new file mode 100644 index 0000000000..058aaadc60 --- /dev/null +++ b/src/hooks/useFilterByLanguages.ts @@ -0,0 +1,57 @@ +import type { FilterByLanguage } from '@app/types/filters'; +import type { FullPublicSettings } from '@server/lib/settings'; +import type { + MovieResult, + PersonResult, + TvResult, +} from '@server/models/Search'; +import { useEffect, useState } from 'react'; +import useSWR from 'swr'; + +interface Props { + titles: (MovieResult | TvResult | PersonResult)[]; + movie: boolean; + tv: boolean; + key?: FilterByLanguage; +} + +const useFilterByLanguages = (props: Readonly) => { + const { titles, movie, tv, key } = props; + + const { data: publicSettings } = useSWR( + '/api/v1/settings/public' + ); + const [originalLanguages, setOriginalLanguages] = useState([]); + const [applyFilter, setApplyFilter] = useState(false); + + useEffect( + function syncFilterByLanguage() { + if (!publicSettings || !key) return; + + setApplyFilter(publicSettings.filters[key]); + + if (publicSettings.originalLanguage) { + const languages = publicSettings.originalLanguage + .split('|') + .map((lang) => lang.trim()); + setOriginalLanguages(languages); + } + }, + [publicSettings, key] + ); + + return titles.filter((title) => { + if (!applyFilter) return true; + + if ( + (movie && title.mediaType === 'movie') || + (tv && title.mediaType === 'tv') + ) { + return originalLanguages.includes(title.originalLanguage); + } + + return true; + }); +}; + +export default useFilterByLanguages; diff --git a/src/types/filters.ts b/src/types/filters.ts new file mode 100644 index 0000000000..e1df05762c --- /dev/null +++ b/src/types/filters.ts @@ -0,0 +1,35 @@ +export enum FilterByLanguage { + SEARCH = 'filterSearch', + TRENDING = 'filterTrending', + SIMILAR_SERIES = 'filterSimilarSeries', + TV_RECOMMENDATIONS = 'filterTvRecommendations', + SIMILAR_MOVIES = 'filterSimilarMovies', + MOVIE_RECOMMENDATIONS = 'filterMovieRecommendations', + TV_UPCOMING = 'filterTvUpcoming', + UPCOMING_MOVIES = 'filterUpcomingMovies', + DISCOVER_MOVIES = 'filterDiscoverMovies', + TV_DISCOVER = 'filterTvDiscover', + POPULAR_MOVIES = 'filterPopularMovies', + TV_POPULAR = 'filterTvPopular', + CUSTOM_SLIDERS = 'filterCustomSliders', +} + +type FilterKey = + | 'filterSearch' + | 'filterTrending' + | 'filterSimilarSeries' + | 'filterSimilarMovies' + | 'filterTvRecommendations' + | 'filterMovieRecommendations' + | 'filterTvUpcoming' + | 'filterUpcomingMovies' + | 'filterDiscoverMovies' + | 'filterTvDiscover' + | 'filterPopularMovies' + | 'filterTvPopular' + | 'filterCustomSliders'; + +export interface FilterItem { + key: FilterKey; + name: string; +}