From 174035d4465171cf2df82900fe6923ff2fe4d44d Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Thu, 19 Jun 2025 17:53:29 +0200 Subject: [PATCH 1/6] feat(discovery): use movieDB endpoint to exclude genres #3293 --- overseerr-api.yml | 10 ++++++++++ server/api/themoviedb/index.ts | 6 ++++++ server/routes/discover.ts | 3 +++ src/components/Discover/FilterSlideover/index.tsx | 15 +++++++++++++++ .../Discover/MovieGenreSlider/index.tsx | 1 + src/components/Discover/constants.ts | 6 +++++- src/components/Discover/index.tsx | 4 ++-- 7 files changed, 42 insertions(+), 3 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index c48b6575f7..cae5d7bd05 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4263,6 +4263,11 @@ paths: schema: type: string example: 18 + - in: query + name: withoutGenre + schema: + type: string + example: 28 - in: query name: studio schema: @@ -4552,6 +4557,11 @@ paths: schema: type: string example: 18 + - in: query + name: withoutGenre + schema: + type: string + example: 28 - in: query name: network schema: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 6bf090b0ff..9add5d2315 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -69,6 +69,7 @@ interface DiscoverMovieOptions { voteCountLte?: string; originalLanguage?: string; genre?: string; + withoutGenre?: string; studio?: string; keywords?: string; sortBy?: SortOptions; @@ -90,6 +91,7 @@ interface DiscoverTvOptions { includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: string; + withoutGenre?: string; network?: number; keywords?: string; sortBy?: SortOptions; @@ -460,6 +462,7 @@ class TheMovieDb extends ExternalAPI { primaryReleaseDateLte, originalLanguage, genre, + withoutGenre, studio, keywords, withRuntimeGte, @@ -506,6 +509,7 @@ class TheMovieDb extends ExternalAPI { ? defaultFutureDate : primaryReleaseDateLte, with_genres: genre, + without_genres: withoutGenre, with_companies: studio, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, @@ -534,6 +538,7 @@ class TheMovieDb extends ExternalAPI { includeEmptyReleaseDate = false, originalLanguage, genre, + withoutGenre, network, keywords, withRuntimeGte, @@ -580,6 +585,7 @@ class TheMovieDb extends ExternalAPI { : this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, with_genres: genre, + without_genres: withoutGenre, with_networks: network, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index b35306446f..2c94fef5dd 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -59,6 +59,7 @@ const QueryFilterOptions = z.object({ firstAirDateLte: z.coerce.string().optional(), studio: z.coerce.string().optional(), genre: z.coerce.string().optional(), + withoutGenre: z.coerce.string().optional(), keywords: z.coerce.string().optional(), language: z.coerce.string().optional(), withRuntimeGte: z.coerce.string().optional(), @@ -86,6 +87,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { language: req.locale ?? query.language, originalLanguage: query.language, genre: query.genre, + withoutGenre: query.withoutGenre, studio: query.studio, primaryReleaseDateLte: query.primaryReleaseDateLte ? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0] @@ -362,6 +364,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, genre: query.genre, + withoutGenre: query.withoutGenre, network: query.network ? Number(query.network) : undefined, firstAirDateLte: query.firstAirDateLte ? new Date(query.firstAirDateLte).toISOString().split('T')[0] diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 83d5a2e49a..164ca272ba 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -29,6 +29,7 @@ const messages = defineMessages({ to: 'To', studio: 'Studio', genres: 'Genres', + withoutGenres: 'Exclude genres', keywords: 'Keywords', originalLanguage: 'Original Language', runtimeText: '{minValue}-{maxValue} minute runtime', @@ -149,6 +150,20 @@ const FilterSlideover = ({ updateQueryParams('genre', value?.map((v) => v.value).join(',')); }} /> + + {intl.formatMessage(messages.withoutGenres)} + + { + updateQueryParams( + 'withoutGenre', + value?.map((v) => v.value).join(',') + ); + }} + /> {intl.formatMessage(messages.keywords)} diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index 106d14a514..b04a0cebe9 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -24,6 +24,7 @@ const MovieGenreSlider = () => { return ( <> + MovieGenreSlider
diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 0571f1fc70..f8019d5c03 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -98,6 +98,7 @@ export const QueryFilterOptions = z.object({ firstAirDateLte: z.string().optional(), studio: z.string().optional(), genre: z.string().optional(), + withoutGenre: z.string().optional(), keywords: z.string().optional(), language: z.string().optional(), withRuntimeGte: z.string().optional(), @@ -118,7 +119,6 @@ export const prepareFilterValues = ( const filterValues: FilterOptions = {}; const values = QueryFilterOptions.parse(inputValues); - if (values.sortBy) { filterValues.sortBy = values.sortBy; } @@ -147,6 +147,10 @@ export const prepareFilterValues = ( filterValues.genre = values.genre; } + if (values.withoutGenre) { + filterValues.withoutGenre = values.withoutGenre; + } + if (values.keywords) { filterValues.keywords = values.keywords; } diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 38875dbedc..263812cd78 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -233,8 +233,8 @@ const Discover = () => { ); break; From 0c6b5cd9221f985cad7920e634c7bcaa0af21fb3 Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Thu, 19 Jun 2025 22:01:24 +0200 Subject: [PATCH 2/6] feat(exclude genres from discovery): feat: Implement comprehensive default excluded genres system Includes user settings for default filtering, discover has automatic filters on these defaults, Popular X feeds have these filters automatically, adding a filtered genre removes it from genres and vice versa implements #3293 --- overseerr-api.yml | 4 +- server/api/themoviedb/index.ts | 12 +-- server/entity/UserSettings.ts | 6 ++ server/interfaces/api/settingsInterfaces.ts | 2 + .../interfaces/api/userSettingsInterfaces.ts | 2 + server/lib/settings.ts | 4 + server/routes/discover.ts | 57 ++++++++++++- server/routes/user/usersettings.ts | 9 ++ .../Discover/DiscoverMovies/index.tsx | 8 +- src/components/Discover/DiscoverTv/index.tsx | 7 +- .../Discover/FilterSlideover/index.tsx | 84 ++++++++++++++++--- src/components/Discover/constants.ts | 17 +++- src/components/Discover/index.tsx | 4 +- src/components/Selector/index.tsx | 22 +++-- .../Settings/SettingsMain/index.tsx | 2 + .../UserGeneralSettings/index.tsx | 52 ++++++++++++ src/context/SettingsContext.tsx | 2 + src/hooks/useUser.ts | 2 + 18 files changed, 260 insertions(+), 36 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index cae5d7bd05..167d3e9762 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4264,7 +4264,7 @@ paths: type: string example: 18 - in: query - name: withoutGenre + name: filterGenre schema: type: string example: 28 @@ -4558,7 +4558,7 @@ paths: type: string example: 18 - in: query - name: withoutGenre + name: filterGenre schema: type: string example: 28 diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 9add5d2315..ab61b19dbd 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -69,7 +69,7 @@ interface DiscoverMovieOptions { voteCountLte?: string; originalLanguage?: string; genre?: string; - withoutGenre?: string; + filterGenre?: string; studio?: string; keywords?: string; sortBy?: SortOptions; @@ -91,7 +91,7 @@ interface DiscoverTvOptions { includeEmptyReleaseDate?: boolean; originalLanguage?: string; genre?: string; - withoutGenre?: string; + filterGenre?: string; network?: number; keywords?: string; sortBy?: SortOptions; @@ -462,7 +462,7 @@ class TheMovieDb extends ExternalAPI { primaryReleaseDateLte, originalLanguage, genre, - withoutGenre, + filterGenre, studio, keywords, withRuntimeGte, @@ -509,7 +509,7 @@ class TheMovieDb extends ExternalAPI { ? defaultFutureDate : primaryReleaseDateLte, with_genres: genre, - without_genres: withoutGenre, + without_genres: filterGenre, with_companies: studio, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, @@ -538,7 +538,7 @@ class TheMovieDb extends ExternalAPI { includeEmptyReleaseDate = false, originalLanguage, genre, - withoutGenre, + filterGenre, network, keywords, withRuntimeGte, @@ -585,7 +585,7 @@ class TheMovieDb extends ExternalAPI { : this.originalLanguage, include_null_first_air_dates: includeEmptyReleaseDate, with_genres: genre, - without_genres: withoutGenre, + without_genres: filterGenre, with_networks: network, with_keywords: keywords, 'with_runtime.gte': withRuntimeGte, diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index ea4a7d33bf..a4394a9887 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -36,6 +36,12 @@ export class UserSettings { @Column({ nullable: true }) public originalLanguage?: string; + @Column({ nullable: true }) + public filterTvGenresDefault?: string; + + @Column({ nullable: true }) + public filterMovieGenresDefault?: string; + @Column({ nullable: true }) public pgpKey?: string; diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 0cd2f171ae..b02e466894 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -30,6 +30,8 @@ export interface PublicSettingsResponse { series4kEnabled: boolean; region: string; originalLanguage: string; + filterMovieGenresDefault: string; + filterTvGenresDefault: string; partialRequestsEnabled: boolean; cacheImages: boolean; vapidPublic: string; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index fb0767b211..daa28ad1b7 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -6,6 +6,8 @@ export interface UserSettingsGeneralResponse { locale?: string; region?: string; originalLanguage?: string; + filterMovieGenresDefault?: string; + filterTvGenresDefault?: string; movieQuotaLimit?: number; movieQuotaDays?: number; tvQuotaLimit?: number; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 63daf17f36..616b3da54f 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -101,6 +101,8 @@ export interface MainSettings { newPlexLogin: boolean; region: string; originalLanguage: string; + filterTvGenresDefault: string; + filterMovieGenresDefault: string; trustProxy: boolean; partialRequestsEnabled: boolean; locale: string; @@ -298,6 +300,8 @@ class Settings { newPlexLogin: true, region: '', originalLanguage: '', + filterTvGenresDefault: '', + filterMovieGenresDefault: '', trustProxy: false, partialRequestsEnabled: true, locale: 'en', diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 2c94fef5dd..cb93465e58 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -59,7 +59,7 @@ const QueryFilterOptions = z.object({ firstAirDateLte: z.coerce.string().optional(), studio: z.coerce.string().optional(), genre: z.coerce.string().optional(), - withoutGenre: z.coerce.string().optional(), + filterGenre: z.coerce.string().optional(), keywords: z.coerce.string().optional(), language: z.coerce.string().optional(), withRuntimeGte: z.coerce.string().optional(), @@ -81,13 +81,38 @@ discoverRoutes.get('/movies', async (req, res, next) => { try { const query = QueryFilterOptions.parse(req.query); const keywords = query.keywords; + + // Handle user default excluded genres + let filterGenre = query.filterGenre; + if (filterGenre === 'none') { + filterGenre = undefined; + } else if ( + filterGenre === undefined && + req.user?.settings?.filterMovieGenresDefault + ) { + filterGenre = req.user.settings.filterMovieGenresDefault; + } + + // Resolve conflicts: when explicit genres are present, remove them from exclusions + if (query.genre && filterGenre) { + const explicitGenres = query.genre.split(','); + const excludedGenres = filterGenre.split(','); + const resolvedExclusions = excludedGenres.filter( + (id) => !explicitGenres.includes(id) + ); + filterGenre = + resolvedExclusions.length > 0 + ? resolvedExclusions.join(',') + : undefined; + } + const data = await tmdb.getDiscoverMovies({ page: Number(query.page), sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, originalLanguage: query.language, genre: query.genre, - withoutGenre: query.withoutGenre, + filterGenre, studio: query.studio, primaryReleaseDateLte: query.primaryReleaseDateLte ? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0] @@ -359,12 +384,38 @@ discoverRoutes.get('/tv', async (req, res, next) => { try { const query = QueryFilterOptions.parse(req.query); const keywords = query.keywords; + + // Handle user default excluded genres + let filterGenre = query.filterGenre; + if (filterGenre === 'none') { + filterGenre = undefined; + } else if ( + filterGenre === undefined && + req.user?.settings?.filterTvGenresDefault + ) { + filterGenre = req.user.settings.filterTvGenresDefault; + } + + // Always resolve conflicts between explicit genres and exclusions + if (query.genre && filterGenre) { + const explicitGenres = query.genre.split(','); + const excludedGenres = filterGenre.split(','); + const resolvedExclusions = excludedGenres.filter( + (id) => !explicitGenres.includes(id) + ); + + filterGenre = + resolvedExclusions.length > 0 + ? resolvedExclusions.join(',') + : undefined; + } + const data = await tmdb.getDiscoverTv({ page: Number(query.page), sortBy: query.sortBy as SortOptions, language: req.locale ?? query.language, genre: query.genre, - withoutGenre: query.withoutGenre, + filterGenre, network: query.network ? Number(query.network) : undefined, firstAirDateLte: query.firstAirDateLte ? new Date(query.firstAirDateLte).toISOString().split('T')[0] diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index c8b3f50bd2..9c782e47e2 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -55,6 +55,8 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( locale: user.settings?.locale, region: user.settings?.region, originalLanguage: user.settings?.originalLanguage, + filterTvGenresDefault: user.settings?.filterTvGenresDefault, + filterMovieGenresDefault: user.settings?.filterMovieGenresDefault, movieQuotaLimit: user.movieQuotaLimit, movieQuotaDays: user.movieQuotaDays, tvQuotaLimit: user.tvQuotaLimit, @@ -116,6 +118,8 @@ userSettingsRoutes.post< locale: req.body.locale, region: req.body.region, originalLanguage: req.body.originalLanguage, + filterMovieGenresDefault: req.body.filterMovieGenresDefault, + filterTvGenresDefault: req.body.filterTvGenresDefault, watchlistSyncMovies: req.body.watchlistSyncMovies, watchlistSyncTv: req.body.watchlistSyncTv, }); @@ -124,6 +128,9 @@ userSettingsRoutes.post< user.settings.locale = req.body.locale; user.settings.region = req.body.region; user.settings.originalLanguage = req.body.originalLanguage; + user.settings.filterMovieGenresDefault = + req.body.filterMovieGenresDefault; + user.settings.filterTvGenresDefault = req.body.filterTvGenresDefault; user.settings.watchlistSyncMovies = req.body.watchlistSyncMovies; user.settings.watchlistSyncTv = req.body.watchlistSyncTv; } @@ -136,6 +143,8 @@ userSettingsRoutes.post< locale: user.settings.locale, region: user.settings.region, originalLanguage: user.settings.originalLanguage, + filterMovieGenresDefault: user.settings.filterMovieGenresDefault, + filterTvGenresDefault: user.settings.filterTvGenresDefault, watchlistSyncMovies: user.settings.watchlistSyncMovies, watchlistSyncTv: user.settings.watchlistSyncTv, }); diff --git a/src/components/Discover/DiscoverMovies/index.tsx b/src/components/Discover/DiscoverMovies/index.tsx index 2cc5117707..617bb1a192 100644 --- a/src/components/Discover/DiscoverMovies/index.tsx +++ b/src/components/Discover/DiscoverMovies/index.tsx @@ -10,6 +10,7 @@ import { import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import { useUser } from '@app/hooks/useUser'; import Error from '@app/pages/_error'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; @@ -47,9 +48,9 @@ const DiscoverMovies = () => { const intl = useIntl(); const router = useRouter(); const updateQueryParams = useUpdateQueryParams({}); + const { user } = useUser(); const preparedFilters = prepareFilterValues(router.query); - const { isLoadingInitialData, isEmpty, @@ -124,7 +125,10 @@ const DiscoverMovies = () => { {intl.formatMessage(messages.activefilters, { - count: countActiveFilters(preparedFilters), + count: countActiveFilters( + preparedFilters, + !!user?.settings?.filterMovieGenresDefault + ), })} diff --git a/src/components/Discover/DiscoverTv/index.tsx b/src/components/Discover/DiscoverTv/index.tsx index 527393676b..bca28ec969 100644 --- a/src/components/Discover/DiscoverTv/index.tsx +++ b/src/components/Discover/DiscoverTv/index.tsx @@ -10,6 +10,7 @@ import { import FilterSlideover from '@app/components/Discover/FilterSlideover'; import useDiscover from '@app/hooks/useDiscover'; import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import { useUser } from '@app/hooks/useUser'; import Error from '@app/pages/_error'; import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid'; import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb'; @@ -46,6 +47,7 @@ const SortOptions: Record = { const DiscoverTv = () => { const intl = useIntl(); const router = useRouter(); + const { user } = useUser(); const [showFilters, setShowFilters] = useState(false); const preparedFilters = prepareFilterValues(router.query); const updateQueryParams = useUpdateQueryParams({}); @@ -122,7 +124,10 @@ const DiscoverTv = () => { {intl.formatMessage(messages.activefilters, { - count: countActiveFilters(preparedFilters), + count: countActiveFilters( + preparedFilters, + !!user?.settings?.filterTvGenresDefault + ), })} diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index 164ca272ba..a4937d3cb5 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -15,6 +15,7 @@ import { useBatchUpdateQueryParams, useUpdateQueryParams, } from '@app/hooks/useUpdateQueryParams'; +import { useUser } from '@app/hooks/useUser'; import { XCircleIcon } from '@heroicons/react/24/outline'; import { defineMessages, useIntl } from 'react-intl'; import Datepicker from 'react-tailwindcss-datepicker-sct'; @@ -29,7 +30,7 @@ const messages = defineMessages({ to: 'To', studio: 'Studio', genres: 'Genres', - withoutGenres: 'Exclude genres', + filterGenres: 'Exclude genres', keywords: 'Keywords', originalLanguage: 'Original Language', runtimeText: '{minValue}-{maxValue} minute runtime', @@ -57,6 +58,7 @@ const FilterSlideover = ({ }: FilterSlideoverProps) => { const intl = useIntl(); const { currentSettings } = useSettings(); + const { user } = useUser(); const updateQueryParams = useUpdateQueryParams({}); const batchUpdateQueryParams = useBatchUpdateQueryParams({}); @@ -65,12 +67,24 @@ const FilterSlideover = ({ const dateLte = type === 'movie' ? 'primaryReleaseDateLte' : 'firstAirDateLte'; + const userDefaultfilterGenres = + type === 'movie' + ? user?.settings?.filterMovieGenresDefault + : user?.settings?.filterTvGenresDefault; + + const filterGenresValue = + currentFilters.filterGenre !== undefined + ? currentFilters.filterGenre === 'none' + ? '' + : currentFilters.filterGenre + : userDefaultfilterGenres; + return ( onClose()} > @@ -147,21 +161,71 @@ const FilterSlideover = ({ defaultValue={currentFilters.genre} isMulti onChange={(value) => { - updateQueryParams('genre', value?.map((v) => v.value).join(',')); + const selectedGenres = value?.map((v) => v.value.toString()) || []; + + // Remove conflicting genres from exclusions + if (selectedGenres.length > 0 && filterGenresValue) { + const hasConflicts = selectedGenres.some((genre) => + filterGenresValue.includes(genre) + ); + if (hasConflicts) { + const cleanedExclusions = filterGenresValue + .split(',') + .filter((id) => !selectedGenres.includes(id)) + .join(','); + + batchUpdateQueryParams({ + genre: selectedGenres.join(',') || undefined, + filterGenre: cleanedExclusions || 'none', + }); + } else { + updateQueryParams( + 'genre', + selectedGenres.join(',') || undefined + ); + } + } else { + updateQueryParams('genre', selectedGenres.join(',') || undefined); + } }} /> - {intl.formatMessage(messages.withoutGenres)} + {intl.formatMessage(messages.filterGenres)} { - updateQueryParams( - 'withoutGenre', - value?.map((v) => v.value).join(',') - ); + const filterGenres = value?.map((v) => v.value.toString()) || []; + + // Remove conflicting genres from inclusions + if (filterGenres.length > 0 && currentFilters.genre) { + const hasConflicts = filterGenres.some((genre) => + currentFilters.genre!.includes(genre) + ); + if (hasConflicts) { + const cleanedInclusions = currentFilters.genre + .split(',') + .filter((id) => !filterGenres.includes(id)) + .join(','); + + batchUpdateQueryParams({ + filterGenre: filterGenres.join(',') || 'none', + genre: cleanedInclusions || undefined, + }); + } else { + updateQueryParams( + 'filterGenre', + filterGenres.join(',') || 'none' + ); + } + } else { + updateQueryParams( + 'filterGenre', + filterGenres.join(',') || 'none' + ); + } }} /> @@ -335,7 +399,7 @@ const FilterSlideover = ({ ( Object.keys(copyCurrent) as (keyof typeof currentFilters)[] ).forEach((k) => { - copyCurrent[k] = undefined; + copyCurrent[k] = k === 'filterGenre' ? 'none' : undefined; }); batchUpdateQueryParams(copyCurrent); onClose(); diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index f8019d5c03..e9fba428af 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -98,7 +98,7 @@ export const QueryFilterOptions = z.object({ firstAirDateLte: z.string().optional(), studio: z.string().optional(), genre: z.string().optional(), - withoutGenre: z.string().optional(), + filterGenre: z.string().optional(), keywords: z.string().optional(), language: z.string().optional(), withRuntimeGte: z.string().optional(), @@ -147,8 +147,8 @@ export const prepareFilterValues = ( filterValues.genre = values.genre; } - if (values.withoutGenre) { - filterValues.withoutGenre = values.withoutGenre; + if (values.filterGenre) { + filterValues.filterGenre = values.filterGenre; } if (values.keywords) { @@ -194,10 +194,19 @@ export const prepareFilterValues = ( return filterValues; }; -export const countActiveFilters = (filterValues: FilterOptions): number => { +export const countActiveFilters = ( + filterValues: FilterOptions, + userDefaultExcludedGenres?: boolean +): number => { let totalCount = 0; const clonedFilters = Object.assign({}, filterValues); + if (clonedFilters.filterGenre === 'none') { + delete clonedFilters.filterGenre; + } else if (!clonedFilters.filterGenre && userDefaultExcludedGenres) { + totalCount += 1; + } + if (clonedFilters.voteAverageGte || filterValues.voteAverageLte) { totalCount += 1; delete clonedFilters.voteAverageGte; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 263812cd78..38875dbedc 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -233,8 +233,8 @@ const Discover = () => { ); break; diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx index 7b21658723..6b9e5772e3 100644 --- a/src/components/Selector/index.tsx +++ b/src/components/Selector/index.tsx @@ -148,6 +148,7 @@ export const GenreSelector = ({ useEffect(() => { const loadDefaultGenre = async (): Promise => { if (!defaultValue) { + setDefaultDataValue([]); return; } @@ -184,21 +185,30 @@ export const GenreSelector = ({ ); }; + const handleChange = ( + value: MultiValue | SingleValue | null + ) => { + if (isMulti) { + setDefaultDataValue(value ? [...(value as MultiValue)] : []); + } else { + setDefaultDataValue(value ? [value as SingleVal] : []); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onChange(value as any); + }; + return ( { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - onChange(value as any); - }} + onChange={handleChange} /> ); }; diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 62f26d49a2..fc2478906b 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -35,6 +35,8 @@ const messages = defineMessages({ regionTip: 'Filter content by regional availability', originallanguage: 'Discover Language', originallanguageTip: 'Filter content by original language', + filterMovieGenresDefault: 'Exclude movie genres by default', + filterTvGenresDefault: 'Exclude TV genres by default', toastApiKeySuccess: 'New API key generated successfully!', toastApiKeyFailure: 'Something went wrong while generating a new API key.', toastSettingsSuccess: 'Settings saved successfully!', diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 842ea7af26..a3cb76de5f 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -5,6 +5,7 @@ import PageTitle from '@app/components/Common/PageTitle'; import LanguageSelector from '@app/components/LanguageSelector'; import QuotaSelector from '@app/components/QuotaSelector'; import RegionSelector from '@app/components/RegionSelector'; +import { GenreSelector } from '@app/components/Selector'; import type { AvailableLocale } from '@app/context/LanguageContext'; import { availableLanguages } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; @@ -40,6 +41,8 @@ const messages = defineMessages({ regionTip: 'Filter content by regional availability', originallanguage: 'Discover Language', originallanguageTip: 'Filter content by original language', + filterMovieGenresDefault: 'Exclude movie genres by default', + filterTvGenresDefault: 'Exclude TV genres by default', movierequestlimit: 'Movie Request Limit', seriesrequestlimit: 'Series Request Limit', enableOverride: 'Override Global Limit', @@ -124,6 +127,8 @@ const UserGeneralSettings = () => { locale: data?.locale, region: data?.region, originalLanguage: data?.originalLanguage, + filterMovieGenresDefault: data?.filterMovieGenresDefault, + filterTvGenresDefault: data?.filterTvGenresDefault, movieQuotaLimit: data?.movieQuotaLimit, movieQuotaDays: data?.movieQuotaDays, tvQuotaLimit: data?.tvQuotaLimit, @@ -141,6 +146,8 @@ const UserGeneralSettings = () => { locale: values.locale, region: values.region, originalLanguage: values.originalLanguage, + filterMovieGenresDefault: values.filterMovieGenresDefault, + filterTvGenresDefault: values.filterTvGenresDefault, movieQuotaLimit: movieQuotaEnabled ? values.movieQuotaLimit : null, @@ -334,6 +341,51 @@ const UserGeneralSettings = () => {
+
+ +
+
+ { + const genreIds = + value?.map((v) => v.value).join(',') || ''; + setFieldValue('filterMovieGenresDefault', genreIds); + }} + /> +
+
+
+
+ +
+
+ { + const genreIds = + value?.map((v) => v.value).join(',') || ''; + setFieldValue('filterTvGenresDefault', genreIds); + }} + /> +
+
+
{currentHasPermission(Permission.MANAGE_USERS) && !hasPermission(Permission.MANAGE_USERS) && ( <> diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index d50add4db7..fad347fccd 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -17,6 +17,8 @@ const defaultSettings = { series4kEnabled: false, region: '', originalLanguage: '', + filterMovieGenresDefault: '', + filterTvGenresDefault: '', partialRequestsEnabled: true, cacheImages: false, vapidPublic: '', diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 192b3fe9da..ed8e4355f8 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -30,6 +30,8 @@ export interface UserSettings { region?: string; originalLanguage?: string; locale?: string; + filterMovieGenresDefault?: string; + filterTvGenresDefault?: string; notificationTypes: Partial; watchlistSyncMovies?: boolean; watchlistSyncTv?: boolean; From 2ccf8b27cf8c4878e57cb89eaf99105a0298edf9 Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Fri, 20 Jun 2025 06:59:01 +0000 Subject: [PATCH 3/6] remove debug text Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/Discover/MovieGenreSlider/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Discover/MovieGenreSlider/index.tsx b/src/components/Discover/MovieGenreSlider/index.tsx index b04a0cebe9..106d14a514 100644 --- a/src/components/Discover/MovieGenreSlider/index.tsx +++ b/src/components/Discover/MovieGenreSlider/index.tsx @@ -24,7 +24,6 @@ const MovieGenreSlider = () => { return ( <> - MovieGenreSlider
From 071dc75a6caa07eb5421623b1a3eeb38bec0a042 Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Fri, 20 Jun 2025 12:34:06 +0200 Subject: [PATCH 4/6] refactor(discover genre filtering extraction to helper function, fix default settings error): ] --- .../Discover/FilterSlideover/index.tsx | 70 ++++++------------- src/components/Discover/constants.ts | 1 + src/pages/_app.tsx | 2 + src/utils/genreHelpers.ts | 26 +++++++ 4 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 src/utils/genreHelpers.ts diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index a4937d3cb5..af0018735c 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -16,6 +16,7 @@ import { useUpdateQueryParams, } from '@app/hooks/useUpdateQueryParams'; import { useUser } from '@app/hooks/useUser'; +import { resolveGenreConflicts } from '@app/utils/genreHelpers'; import { XCircleIcon } from '@heroicons/react/24/outline'; import { defineMessages, useIntl } from 'react-intl'; import Datepicker from 'react-tailwindcss-datepicker-sct'; @@ -162,30 +163,18 @@ const FilterSlideover = ({ isMulti onChange={(value) => { const selectedGenres = value?.map((v) => v.value.toString()) || []; + const result = resolveGenreConflicts( + selectedGenres, + filterGenresValue + ); - // Remove conflicting genres from exclusions - if (selectedGenres.length > 0 && filterGenresValue) { - const hasConflicts = selectedGenres.some((genre) => - filterGenresValue.includes(genre) - ); - if (hasConflicts) { - const cleanedExclusions = filterGenresValue - .split(',') - .filter((id) => !selectedGenres.includes(id)) - .join(','); - - batchUpdateQueryParams({ - genre: selectedGenres.join(',') || undefined, - filterGenre: cleanedExclusions || 'none', - }); - } else { - updateQueryParams( - 'genre', - selectedGenres.join(',') || undefined - ); - } + if (result.hasConflicts) { + batchUpdateQueryParams({ + genre: result.changingList, + filterGenre: result.otherList || 'none', + }); } else { - updateQueryParams('genre', selectedGenres.join(',') || undefined); + updateQueryParams('genre', result.changingList); } }} /> @@ -198,33 +187,18 @@ const FilterSlideover = ({ isMulti onChange={(value) => { const filterGenres = value?.map((v) => v.value.toString()) || []; + const result = resolveGenreConflicts( + filterGenres, + currentFilters.genre + ); - // Remove conflicting genres from inclusions - if (filterGenres.length > 0 && currentFilters.genre) { - const hasConflicts = filterGenres.some((genre) => - currentFilters.genre!.includes(genre) - ); - if (hasConflicts) { - const cleanedInclusions = currentFilters.genre - .split(',') - .filter((id) => !filterGenres.includes(id)) - .join(','); - - batchUpdateQueryParams({ - filterGenre: filterGenres.join(',') || 'none', - genre: cleanedInclusions || undefined, - }); - } else { - updateQueryParams( - 'filterGenre', - filterGenres.join(',') || 'none' - ); - } + if (result.hasConflicts) { + batchUpdateQueryParams({ + genre: result.otherList, + filterGenre: result.changingList || 'none', + }); } else { - updateQueryParams( - 'filterGenre', - filterGenres.join(',') || 'none' - ); + updateQueryParams('filterGenre', result.changingList || 'none'); } }} /> @@ -399,7 +373,7 @@ const FilterSlideover = ({ ( Object.keys(copyCurrent) as (keyof typeof currentFilters)[] ).forEach((k) => { - copyCurrent[k] = k === 'filterGenre' ? 'none' : undefined; + copyCurrent[k] = undefined; }); batchUpdateQueryParams(copyCurrent); onClose(); diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index e9fba428af..73ed3eea6a 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -119,6 +119,7 @@ export const prepareFilterValues = ( const filterValues: FilterOptions = {}; const values = QueryFilterOptions.parse(inputValues); + if (values.sortBy) { filterValues.sortBy = values.sortBy; } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 091bb03c7d..5f192e8d6e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -235,6 +235,8 @@ CoreApp.getInitialProps = async (initialProps) => { localLogin: true, region: '', originalLanguage: '', + filterMovieGenresDefault: '', + filterTvGenresDefault: '', partialRequestsEnabled: true, cacheImages: false, vapidPublic: '', diff --git a/src/utils/genreHelpers.ts b/src/utils/genreHelpers.ts new file mode 100644 index 0000000000..03dc1fe4a1 --- /dev/null +++ b/src/utils/genreHelpers.ts @@ -0,0 +1,26 @@ +export function resolveGenreConflicts( + changingList: string[], + otherList?: string +): { + changingList: string | undefined; + otherList: string | undefined; + hasConflicts: boolean; +} { + const hasConflicts = otherList + ? changingList.some((genre) => otherList.includes(genre)) + : false; + + const cleanedOtherList = + hasConflicts && otherList + ? otherList + .split(',') + .filter((id) => !changingList.includes(id)) + .join(',') || undefined + : otherList; + + return { + changingList: changingList.length > 0 ? changingList.join(',') : undefined, + otherList: cleanedOtherList, + hasConflicts, + }; +} From 369820be5dc2c3218b7bc9aec3b31e0940188155 Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Fri, 20 Jun 2025 13:33:40 +0200 Subject: [PATCH 5/6] chore(comments): cleanup --- server/routes/discover.ts | 9 ++------- src/components/Discover/DiscoverMovies/index.tsx | 1 + 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/server/routes/discover.ts b/server/routes/discover.ts index cb93465e58..c23883a7e7 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -82,7 +82,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { const query = QueryFilterOptions.parse(req.query); const keywords = query.keywords; - // Handle user default excluded genres + // Handle user default excluded genres, resolve genres let filterGenre = query.filterGenre; if (filterGenre === 'none') { filterGenre = undefined; @@ -92,8 +92,6 @@ discoverRoutes.get('/movies', async (req, res, next) => { ) { filterGenre = req.user.settings.filterMovieGenresDefault; } - - // Resolve conflicts: when explicit genres are present, remove them from exclusions if (query.genre && filterGenre) { const explicitGenres = query.genre.split(','); const excludedGenres = filterGenre.split(','); @@ -385,7 +383,7 @@ discoverRoutes.get('/tv', async (req, res, next) => { const query = QueryFilterOptions.parse(req.query); const keywords = query.keywords; - // Handle user default excluded genres + // Handle user default excluded genres, resolve genres let filterGenre = query.filterGenre; if (filterGenre === 'none') { filterGenre = undefined; @@ -395,15 +393,12 @@ discoverRoutes.get('/tv', async (req, res, next) => { ) { filterGenre = req.user.settings.filterTvGenresDefault; } - - // Always resolve conflicts between explicit genres and exclusions if (query.genre && filterGenre) { const explicitGenres = query.genre.split(','); const excludedGenres = filterGenre.split(','); const resolvedExclusions = excludedGenres.filter( (id) => !explicitGenres.includes(id) ); - filterGenre = resolvedExclusions.length > 0 ? resolvedExclusions.join(',') diff --git a/src/components/Discover/DiscoverMovies/index.tsx b/src/components/Discover/DiscoverMovies/index.tsx index 617bb1a192..37d097de7c 100644 --- a/src/components/Discover/DiscoverMovies/index.tsx +++ b/src/components/Discover/DiscoverMovies/index.tsx @@ -51,6 +51,7 @@ const DiscoverMovies = () => { const { user } = useUser(); const preparedFilters = prepareFilterValues(router.query); + const { isLoadingInitialData, isEmpty, From f307a3667c9c782c2639c93660b5d33330a4fb02 Mon Sep 17 00:00:00 2001 From: Michael Hoeykens Date: Fri, 20 Jun 2025 13:39:10 +0200 Subject: [PATCH 6/6] chore(i18n): i18n for en+nl --- src/i18n/locale/en.json | 5 +++++ src/i18n/locale/nl.json | 1 + 2 files changed, 6 insertions(+) diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 57ef71f8f7..03687f439e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -63,6 +63,7 @@ "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", "components.Discover.FilterSlideover.clearfilters": "Clear Active Filters", + "components.Discover.FilterSlideover.filterGenres": "Exclude genres", "components.Discover.FilterSlideover.filters": "Filters", "components.Discover.FilterSlideover.firstAirDate": "First Air Date", "components.Discover.FilterSlideover.from": "From", @@ -800,6 +801,8 @@ "components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection", "components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!", "components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)", + "components.Settings.SettingsMain.filterMovieGenresDefault": "Exclude movie genres by default", + "components.Settings.SettingsMain.filterTvGenresDefault": "Exclude TV genres by default", "components.Settings.SettingsMain.general": "General", "components.Settings.SettingsMain.generalsettings": "General Settings", "components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Overseerr.", @@ -1085,6 +1088,8 @@ "components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "The multi-digit ID number associated with your Discord user account", "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", "components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Override Global Limit", + "components.UserProfile.UserSettings.UserGeneralSettings.filterMovieGenresDefault": "Exclude movie genres by default", + "components.UserProfile.UserSettings.UserGeneralSettings.filterTvGenresDefault": "Exclude TV genres by default", "components.UserProfile.UserSettings.UserGeneralSettings.general": "General", "components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings", "components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "Default ({language})", diff --git a/src/i18n/locale/nl.json b/src/i18n/locale/nl.json index f081b400a2..fc3a1483a1 100644 --- a/src/i18n/locale/nl.json +++ b/src/i18n/locale/nl.json @@ -1157,6 +1157,7 @@ "components.Discover.DiscoverTv.sortTitleDesc": "Titel (Z-A) aflopend", "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# filter actief} other {# filters actief}}", "components.Discover.FilterSlideover.clearfilters": "Actieve filters wissen", + "components.Discover.FilterSlideover.filterGenres": "Filter genres", "components.Discover.FilterSlideover.filters": "Filters", "components.Discover.FilterSlideover.firstAirDate": "Eerste uitzenddatum", "components.Discover.FilterSlideover.from": "Van",