From 01fd71180fc1adf1be242363a324336d62a5854b Mon Sep 17 00:00:00 2001 From: Jay the Reaper <198331141+TheReaperJay@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:32:53 +0700 Subject: [PATCH 1/6] feat(discover): add exclusionary filters with per-dimension include/exclude toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-dimension exclusion (negation) support to discover. Each filter section now has an Included/Excluded slide switch; the active mode drives whether the selector writes to the include or exclude URL param, and the value transfers when toggling so users don't need to re-select. Exclusion is implemented with three mechanisms, dispatched centrally in buildDiscoverPlan(): - TMDB-native without_* params (genres, keywords, studio, providers) - Post-filter on list-response fields (language on both, country on TV) - Complement query where TMDB has no without_* param (movie country, TV status) A bounded over-fetch (max 3 extra pages) fills the requested page when a post-filter drops items, and the response carries paginationIsEstimate so the UI can label the total as an estimate. A capabilities matrix (FILTER_CAPABILITIES) drives which sections render an exclude toggle, so dimensions TMDB cannot exclude (e.g. watch providers) show a plain heading instead of a non-functional toggle. The flat and structured filter shapes are defined once and imported by both the route handlers and the client. Route handlers are simplified to parse → plan → fill → enrich → respond. The TMDB wrapper gains without_* params, with_origin_country, and a country param, reusing the existing nodeCache layer. --- seerr-api.yml | 72 +++ server/api/themoviedb/index.ts | 23 + server/discover/countryCodes.ts | 24 + server/discover/fill.ts | 77 +++ server/discover/planBuilder.ts | 143 +++++ server/discover/postFilter.ts | 41 ++ server/routes/discover.ts | 209 +++--- server/tsconfig.json | 5 +- shared/discover/capabilities.ts | 61 ++ shared/discover/schema.ts | 158 +++++ shared/discover/types.ts | 104 +++ src/components/Common/SlideCheckbox/index.tsx | 4 +- .../Discover/DiscoverMovies/index.tsx | 8 + src/components/Discover/DiscoverTv/index.tsx | 10 +- .../Discover/FilterSlideover/index.tsx | 597 ++++++++++++------ src/components/Discover/constants.ts | 35 + src/components/Selector/index.tsx | 85 ++- src/hooks/useDiscover.ts | 1 + tsconfig.json | 3 +- 19 files changed, 1323 insertions(+), 337 deletions(-) create mode 100644 server/discover/countryCodes.ts create mode 100644 server/discover/fill.ts create mode 100644 server/discover/planBuilder.ts create mode 100644 server/discover/postFilter.ts create mode 100644 shared/discover/capabilities.ts create mode 100644 shared/discover/schema.ts create mode 100644 shared/discover/types.ts diff --git a/seerr-api.yml b/seerr-api.yml index 18f3361d6a..45a75925f6 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -5538,6 +5538,42 @@ paths: enum: [exact, range] example: exact description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + - in: query + name: excludeGenres + schema: + type: string + example: '27,53' + description: Comma-separated genre IDs to exclude + - in: query + name: excludeStudio + schema: + type: string + example: '1,2' + description: Comma-separated studio IDs to exclude + - in: query + name: excludeWatchProviders + schema: + type: string + example: '8|9' + description: Pipe-separated watch provider IDs to exclude + - in: query + name: country + schema: + type: string + example: 'US,GB' + description: Comma-separated ISO 3166-1 country codes + - in: query + name: excludeCountries + schema: + type: string + example: 'IN,CN' + description: Comma-separated ISO 3166-1 country codes to exclude + - in: query + name: excludeLanguages + schema: + type: string + example: 'en,fr' + description: Comma-separated ISO 639-1 language codes to exclude responses: '200': description: Results @@ -5869,6 +5905,42 @@ paths: enum: [exact, range] example: exact description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + - in: query + name: excludeGenres + schema: + type: string + example: '27,53' + description: Comma-separated genre IDs to exclude + - in: query + name: excludeWatchProviders + schema: + type: string + example: '8|9' + description: Pipe-separated watch provider IDs to exclude + - in: query + name: country + schema: + type: string + example: 'US,GB' + description: Comma-separated ISO 3166-1 country codes + - in: query + name: excludeCountries + schema: + type: string + example: 'IN,CN' + description: Comma-separated ISO 3166-1 country codes to exclude + - in: query + name: excludeLanguages + schema: + type: string + example: 'en,fr' + description: Comma-separated ISO 639-1 language codes to exclude + - in: query + name: excludeStatus + schema: + type: string + example: '2,3' + description: Comma-separated status IDs to exclude responses: '200': description: Results diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 305d6048d7..87df594819 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -95,6 +95,11 @@ interface DiscoverMovieOptions { certificationGte?: string; certificationLte?: string; certificationCountry?: string; + excludeGenres?: string; + excludeStudio?: string; + excludeWatchProviders?: string; + country?: string; + originCountryParam?: string; } interface DiscoverTvOptions { @@ -122,6 +127,9 @@ interface DiscoverTvOptions { certificationGte?: string; certificationLte?: string; certificationCountry?: string; + excludeGenres?: string; + excludeWatchProviders?: string; + country?: string; } class TheMovieDb extends ExternalAPI implements TvShowProvider { @@ -607,6 +615,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { certificationGte, certificationLte, certificationCountry, + excludeGenres, + excludeStudio, + excludeWatchProviders, + country, + originCountryParam, }: DiscoverMovieOptions = {}): Promise => { try { const defaultFutureDate = new Date( @@ -659,6 +672,10 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { 'certification.gte': certificationGte, 'certification.lte': certificationLte, certification_country: certificationCountry, + without_genres: excludeGenres, + without_companies: excludeStudio, + without_watch_providers: excludeWatchProviders, + with_origin_country: originCountryParam ?? country, }, }); @@ -695,6 +712,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { certificationGte, certificationLte, certificationCountry, + excludeGenres, + excludeWatchProviders, + country, }: DiscoverTvOptions = {}): Promise => { try { const defaultFutureDate = new Date( @@ -747,6 +767,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { 'certification.gte': certificationGte, 'certification.lte': certificationLte, certification_country: certificationCountry, + without_genres: excludeGenres, + without_watch_providers: excludeWatchProviders, + with_origin_country: country, }, }); diff --git a/server/discover/countryCodes.ts b/server/discover/countryCodes.ts new file mode 100644 index 0000000000..a226cbca2f --- /dev/null +++ b/server/discover/countryCodes.ts @@ -0,0 +1,24 @@ +import type TheMovieDb from '@server/api/themoviedb'; + +/** + * Returns all ISO 3166-1 country codes. Reuses the existing `getRegions()` + * method which is already cached for 24 hours via nodeCache. + */ +export async function getAllCountryCodes(tmdb: TheMovieDb): Promise { + const regions = await tmdb.getRegions(); + return regions.map((r) => r.iso_3166_1).filter(Boolean); +} + +/** + * Given the full set of codes and a subset to exclude, returns a pipe-joined + * string of everything EXCEPT the excluded codes. TMDB has no way to exclude + * an origin country directly, so we instead ask it to include the complement. + */ +export function buildComplement(allCodes: string[], exclude: string[]): string { + const excludeSet = new Set(exclude); + const result = allCodes.filter((c) => !excludeSet.has(c)).join('|'); + if (result.length > 7500) { + throw new Error('Complement string exceeds safe URL length'); + } + return result; +} diff --git a/server/discover/fill.ts b/server/discover/fill.ts new file mode 100644 index 0000000000..b31c10af51 --- /dev/null +++ b/server/discover/fill.ts @@ -0,0 +1,77 @@ +import { PAGE_SIZE } from '@server/discover/planBuilder'; +import { applyPostFilter } from '@server/discover/postFilter'; +import type { + HonestPage, + ListResult, + PostFilterSpec, +} from '@shared/discover/types'; + +interface TmdbPage { + results: T[]; + total_pages: number; + total_results: number; +} + +/** + * Fetch TMDB discover pages and return a result whose pagination tells the + * truth about what the user will actually see. + * + * When no post-filter is active (common case), exactly one TMDB call is made + * and the response is honest. + * + * When a post-filter IS active (language exclude, TV country exclude), TMDB's + * total_results is a pre-filter count. To fill the requested page with real + * results we may need to over-fetch — up to `maxOverFetch` extra pages. This + * is a bounded trade of correctness for calls. + */ +export async function fillPage( + fetch: (page: number) => Promise>, + postFilter: PostFilterSpec, + requestedPage: number, + maxOverFetch = 3 +): Promise> { + const hasPostFilter = + !!postFilter.excludeLanguages?.length || + !!postFilter.excludeCountries?.length; + + if (!hasPostFilter) { + const res = await fetch(requestedPage); + return { + page: requestedPage, + totalPages: res.total_pages, + totalResults: res.total_results, + results: res.results, + paginationIsEstimate: false, + }; + } + + const collected: T[] = []; + let tmdbPage = requestedPage; + let extra = 0; + let lastTotalPages = 0; + let lastTotalResults = 0; + let anyDropped = false; + + while (collected.length < PAGE_SIZE) { + const res = await fetch(tmdbPage); + lastTotalPages = res.total_pages; + lastTotalResults = res.total_results; + + const { filtered, dropped } = applyPostFilter(res.results, postFilter); + if (dropped > 0) anyDropped = true; + collected.push(...filtered); + + if (extra >= maxOverFetch) break; + if (tmdbPage >= res.total_pages) break; + tmdbPage++; + extra++; + } + + return { + page: requestedPage, + totalPages: lastTotalPages, + totalResults: lastTotalResults, + results: collected.slice(0, PAGE_SIZE), + paginationIsEstimate: anyDropped, + }; +} diff --git a/server/discover/planBuilder.ts b/server/discover/planBuilder.ts new file mode 100644 index 0000000000..fc95ca7fd7 --- /dev/null +++ b/server/discover/planBuilder.ts @@ -0,0 +1,143 @@ +import { buildComplement } from '@server/discover/countryCodes'; +import type { + DiscoverFilter, + DiscoverPlan, + PostFilterSpec, +} from '@shared/discover/types'; + +const PAGE_SIZE = 20; + +const ALL_TV_STATUS = ['0', '1', '2', '3', '4', '5']; + +/** Normalise a date string to YYYY-MM-DD as TMDB expects. */ +const normalizeDate = (d?: string): string | undefined => + d ? new Date(d).toISOString().split('T')[0] : undefined; + +const join = (arr?: string[]): string | undefined => + arr?.length ? arr.join(',') : undefined; + +/** + * Translate a structured discover filter into the options the TMDB wrapper + * needs plus any post-filter rules the route handler must apply after TMDB + * returns. Centralising this here keeps the route handlers thin. + * + * Some dimensions are filtered natively by TMDB (genres, companies, watch + * providers). Others cannot be, because the data source exposes no param or + * no field on the list response — those are handled either by post-filtering + * the returned items (language) or by asking TMDB to include the complement + * of what we want to exclude (movie country, TV status). + */ +export function buildDiscoverPlan( + filter: DiscoverFilter, + mediaType: 'movie' | 'tv', + allCountryCodes: string[] +): DiscoverPlan { + const discoverOptions: Record = {}; + const postFilter: PostFilterSpec = {}; + + // Normalise: ensure every dimension exists (the schema always produces a + // full object, but callers may pass partials). + const dim = (d: typeof filter.genres | undefined) => d ?? {}; + + // ── Sort / page ── + if (filter.sortBy) discoverOptions.sortBy = filter.sortBy; + + // Genres — TMDB filters natively via with_genres / without_genres + if (dim(filter.genres).include?.length) + discoverOptions.genre = filter.genres.include!.join(','); + if (dim(filter.genres).exclude?.length) + discoverOptions.excludeGenres = filter.genres.exclude!.join(','); + + // Keywords — TMDB filters natively via with_keywords / without_keywords + discoverOptions.keywords = join(dim(filter.keywords).include); + if (dim(filter.keywords).exclude?.length) + discoverOptions.excludeKeywords = filter.keywords!.exclude!.join(','); + + // Studio — movies only (TMDB: with_companies / without_companies) + if (mediaType === 'movie') { + discoverOptions.studio = join(dim(filter.studio).include); + if (dim(filter.studio).exclude?.length) + discoverOptions.excludeStudio = filter.studio!.exclude!.join(','); + } + + // Watch providers — TMDB filters natively via with_watch_providers + // (exclude is technically possible via without_watch_providers but not + // surfaced in the UI — see FILTER_CAPABILITIES). + discoverOptions.watchProviders = join(dim(filter.watchProviders).include); + + // Language — original_language is on both movie and TV list responses, so + // exclude can be applied as a post-filter. Include uses the native param. + if (dim(filter.language).include?.length) + discoverOptions.originalLanguage = filter.language!.include!.join('|'); + if (dim(filter.language).exclude?.length) + postFilter.excludeLanguages = filter.language!.exclude; + + // Country — origin_country is absent from the movie list response but + // present on TV. Excluding a movie country therefore has to be done by + // asking TMDB to include the complement; excluding a TV country can be + // done locally after TMDB returns. + if (mediaType === 'movie') { + if (dim(filter.country).exclude?.length) { + discoverOptions.originCountryParam = buildComplement( + allCountryCodes, + filter.country!.exclude! + ); + } else if (dim(filter.country).include?.length) { + discoverOptions.originCountryParam = filter.country!.include!.join('|'); + } + } else { + if (dim(filter.country).include?.length) + discoverOptions.country = filter.country!.include!.join('|'); + if (dim(filter.country).exclude?.length) + postFilter.excludeCountries = filter.country!.exclude; + } + + // TV status — no without_status param, so exclude by asking for the + // complement of the excluded values via with_status. + if (mediaType === 'tv') { + if (dim(filter.status).exclude?.length) { + discoverOptions.withStatus = buildComplement( + ALL_TV_STATUS, + filter.status!.exclude! + ); + } else if (dim(filter.status).include?.length) { + discoverOptions.withStatus = filter.status!.include!.join('|'); + } + } + + // ── Ranges ── + if (mediaType === 'movie' && filter.primaryReleaseDate) { + discoverOptions.primaryReleaseDateGte = normalizeDate( + filter.primaryReleaseDate.gte + ); + discoverOptions.primaryReleaseDateLte = normalizeDate( + filter.primaryReleaseDate.lte + ); + } + if (mediaType === 'tv' && filter.firstAirDate) { + discoverOptions.firstAirDateGte = normalizeDate(filter.firstAirDate.gte); + discoverOptions.firstAirDateLte = normalizeDate(filter.firstAirDate.lte); + } + if (filter.runtime) { + discoverOptions.withRuntimeGte = filter.runtime.gte; + discoverOptions.withRuntimeLte = filter.runtime.lte; + } + if (filter.voteAverage) { + discoverOptions.voteAverageGte = filter.voteAverage.gte; + discoverOptions.voteAverageLte = filter.voteAverage.lte; + } + if (filter.voteCount) { + discoverOptions.voteCountGte = filter.voteCount.gte; + discoverOptions.voteCountLte = filter.voteCount.lte; + } + if (filter.certification) { + discoverOptions.certification = filter.certification.value; + discoverOptions.certificationGte = filter.certification.gte; + discoverOptions.certificationLte = filter.certification.lte; + discoverOptions.certificationCountry = filter.certification.country; + } + + return { discoverOptions, postFilter }; +} + +export { PAGE_SIZE }; diff --git a/server/discover/postFilter.ts b/server/discover/postFilter.ts new file mode 100644 index 0000000000..d110d12c3f --- /dev/null +++ b/server/discover/postFilter.ts @@ -0,0 +1,41 @@ +import type { ListResult, PostFilterSpec } from '@shared/discover/types'; + +/** + * Apply post-filter rules to discover results that have already been returned + * by TMDB. Used for exclusion dimensions where TMDB has no native `without_*` + * param and the relevant field IS present on the list response (language on + * both movie/TV, origin_country on TV only). + * + * Pure function — no imports from the API, route, or DB layers. + */ +export function applyPostFilter( + results: T[], + spec: PostFilterSpec +): { filtered: T[]; dropped: number } { + const langSet = spec.excludeLanguages?.length + ? new Set(spec.excludeLanguages) + : null; + const countrySet = spec.excludeCountries?.length + ? new Set(spec.excludeCountries) + : null; + + if (!langSet && !countrySet) { + return { filtered: results, dropped: 0 }; + } + + const filtered = results.filter((item) => { + if ( + langSet && + item.original_language && + langSet.has(item.original_language) + ) { + return false; + } + if (countrySet && item.origin_country?.some((c) => countrySet.has(c))) { + return false; + } + return true; + }); + + return { filtered, dropped: results.length - filtered.length }; +} diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..8263e32ce3 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,9 +1,11 @@ import PlexTvAPI from '@server/api/plextv'; -import type { SortOptions } from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import { getAllCountryCodes } from '@server/discover/countryCodes'; +import { fillPage } from '@server/discover/fill'; +import { buildDiscoverPlan } from '@server/discover/planBuilder'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; @@ -22,9 +24,9 @@ import { } from '@server/models/Search'; import { mapNetwork } from '@server/models/Tv'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; +import { DiscoverFilterSchema } from '@shared/discover/schema'; import { Router } from 'express'; import { sortBy } from 'lodash'; -import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -60,111 +62,60 @@ export const createTmdbWithBlocklistSettings = (): TheMovieDb => { const discoverRoutes = Router(); -const QueryFilterOptions = z.object({ - page: z.coerce.string().optional(), - sortBy: z.coerce.string().optional(), - primaryReleaseDateGte: z.coerce.string().optional(), - primaryReleaseDateLte: z.coerce.string().optional(), - firstAirDateGte: z.coerce.string().optional(), - firstAirDateLte: z.coerce.string().optional(), - studio: z.coerce.string().optional(), - genre: z.coerce.string().optional(), - keywords: z.coerce.string().optional(), - excludeKeywords: z.coerce.string().optional(), - language: z.coerce.string().optional(), - withRuntimeGte: z.coerce.string().optional(), - withRuntimeLte: z.coerce.string().optional(), - voteAverageGte: z.coerce.string().optional(), - voteAverageLte: z.coerce.string().optional(), - voteCountGte: z.coerce.string().optional(), - voteCountLte: z.coerce.string().optional(), - network: z.coerce.string().optional(), - watchProviders: z.coerce.string().optional(), - watchRegion: z.coerce.string().optional(), - status: z.coerce.string().optional(), - certification: z.coerce.string().optional(), - certificationGte: z.coerce.string().optional(), - certificationLte: z.coerce.string().optional(), - certificationCountry: z.coerce.string().optional(), - certificationMode: z.enum(['exact', 'range']).optional(), -}); - -export type FilterOptions = z.infer; -const ApiQuerySchema = QueryFilterOptions.omit({ - certificationMode: true, -}); - discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { - const query = ApiQuerySchema.parse(req.query); - const keywords = query.keywords; - const excludeKeywords = query.excludeKeywords; - - 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, - studio: query.studio, - primaryReleaseDateLte: query.primaryReleaseDateLte - ? new Date(query.primaryReleaseDateLte).toISOString().split('T')[0] - : undefined, - primaryReleaseDateGte: query.primaryReleaseDateGte - ? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0] - : undefined, - keywords, - excludeKeywords, - withRuntimeGte: query.withRuntimeGte, - withRuntimeLte: query.withRuntimeLte, - voteAverageGte: query.voteAverageGte, - voteAverageLte: query.voteAverageLte, - voteCountGte: query.voteCountGte, - voteCountLte: query.voteCountLte, - watchProviders: query.watchProviders, - watchRegion: query.watchRegion, - certification: query.certification, - certificationGte: query.certificationGte, - certificationLte: query.certificationLte, - certificationCountry: query.certificationCountry, - }); + const filter = DiscoverFilterSchema.parse(req.query); + const countryCodes = await getAllCountryCodes(tmdb); + const plan = buildDiscoverPlan(filter, 'movie', countryCodes); + + const page = await fillPage( + (p) => + tmdb.getDiscoverMovies({ + ...(plan.discoverOptions as object), + page: p, + language: req.locale, + watchRegion: req.query.watchRegion as string | undefined, + }), + plan.postFilter, + filter.page ?? 1 + ); const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + page.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.MOVIE, })) ); + const keywordIds = [ + ...(filter.keywords.include ?? []), + ...(filter.keywords.exclude ?? []), + ]; let keywordData: TmdbKeyword[] = []; - if (keywords) { - const splitKeywords = keywords.split(','); - - const keywordResults = await Promise.all( - splitKeywords.map(async (keywordId) => { - return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); - }) - ); - - keywordData = keywordResults.filter( - (keyword): keyword is TmdbKeyword => keyword !== null - ); + if (keywordIds.length) { + keywordData = ( + await Promise.all( + keywordIds.map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) + ) + ).filter((keyword): keyword is TmdbKeyword => keyword !== null); } return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, + page: page.page, + totalPages: page.totalPages, + totalResults: page.totalResults, + paginationIsEstimate: page.paginationIsEstimate, keywords: keywordData, - results: data.results.map((result) => + results: page.results.map((result) => mapMovieResult( result, media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + (m) => m.tmdbId === result.id && m.mediaType === MediaType.MOVIE ) ) ), @@ -406,72 +357,56 @@ discoverRoutes.get('/tv', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); try { - const query = ApiQuerySchema.parse(req.query); - const keywords = query.keywords; - const excludeKeywords = query.excludeKeywords; - const data = await tmdb.getDiscoverTv({ - page: Number(query.page), - sortBy: query.sortBy as SortOptions, - language: req.locale ?? query.language, - genre: query.genre, - network: query.network ? Number(query.network) : undefined, - firstAirDateLte: query.firstAirDateLte - ? new Date(query.firstAirDateLte).toISOString().split('T')[0] - : undefined, - firstAirDateGte: query.firstAirDateGte - ? new Date(query.firstAirDateGte).toISOString().split('T')[0] - : undefined, - originalLanguage: query.language, - keywords, - excludeKeywords, - withRuntimeGte: query.withRuntimeGte, - withRuntimeLte: query.withRuntimeLte, - voteAverageGte: query.voteAverageGte, - voteAverageLte: query.voteAverageLte, - voteCountGte: query.voteCountGte, - voteCountLte: query.voteCountLte, - watchProviders: query.watchProviders, - watchRegion: query.watchRegion, - withStatus: query.status, - certification: query.certification, - certificationGte: query.certificationGte, - certificationLte: query.certificationLte, - certificationCountry: query.certificationCountry, - }); + const filter = DiscoverFilterSchema.parse(req.query); + const countryCodes = await getAllCountryCodes(tmdb); + const plan = buildDiscoverPlan(filter, 'tv', countryCodes); + + const page = await fillPage( + (p) => + tmdb.getDiscoverTv({ + ...(plan.discoverOptions as object), + page: p, + language: req.locale, + watchRegion: req.query.watchRegion as string | undefined, + }), + plan.postFilter, + filter.page ?? 1 + ); const media = await Media.getRelatedMedia( req.user, - data.results.map((result) => ({ + page.results.map((result) => ({ tmdbId: result.id, mediaType: MediaType.TV, })) ); + const keywordIds = [ + ...(filter.keywords.include ?? []), + ...(filter.keywords.exclude ?? []), + ]; let keywordData: TmdbKeyword[] = []; - if (keywords) { - const splitKeywords = keywords.split(','); - - const keywordResults = await Promise.all( - splitKeywords.map(async (keywordId) => { - return await tmdb.getKeywordDetails({ keywordId: Number(keywordId) }); - }) - ); - - keywordData = keywordResults.filter( - (keyword): keyword is TmdbKeyword => keyword !== null - ); + if (keywordIds.length) { + keywordData = ( + await Promise.all( + keywordIds.map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) + ) + ).filter((keyword): keyword is TmdbKeyword => keyword !== null); } return res.status(200).json({ - page: data.page, - totalPages: data.total_pages, - totalResults: data.total_results, + page: page.page, + totalPages: page.totalPages, + totalResults: page.totalResults, + paginationIsEstimate: page.paginationIsEstimate, keywords: keywordData, - results: data.results.map((result) => + results: page.results.map((result) => mapTvResult( result, media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + (m) => m.tmdbId === result.id && m.mediaType === MediaType.TV ) ) ), diff --git a/server/tsconfig.json b/server/tsconfig.json index aff5f8c37f..bbed0bd02d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -9,8 +9,9 @@ "incremental": true, "baseUrl": ".", "paths": { - "@server/*": ["*"] + "@server/*": ["*"], + "@shared/*": ["../shared/*"] } }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "../shared/**/*.ts"] } diff --git a/shared/discover/capabilities.ts b/shared/discover/capabilities.ts new file mode 100644 index 0000000000..2c4a9f73e7 --- /dev/null +++ b/shared/discover/capabilities.ts @@ -0,0 +1,61 @@ +/** + * Capabilities matrix — the single source of truth for what the discover + * filter UI can do per (dimension, mediaType). + * + * The UI reads this table to decide two things: + * 1. Whether a section renders at all → include || exclude + * 2. Whether the Include/Excluded toggle renders → exclude + * + * Every entry here must correspond to a real section in `FilterSlideover`. + * The `false` values exist because the underlying data source (TMDB's + * discover endpoint) makes the operation impossible — not because the feature + * is unfinished. + */ +import type { DimensionKey } from './types'; + +export type Capability = { include: boolean; exclude: boolean }; + +export const FILTER_CAPABILITIES: { + movie: Record; + tv: Record; +} = { + movie: { + genres: { include: true, exclude: true }, // TMDB: with_genres + without_genres + keywords: { include: true, exclude: true }, // TMDB: with_keywords + without_keywords + studio: { include: true, exclude: true }, // TMDB: with_companies + without_companies + watchProviders: { + include: true, + // TMDB exposes without_watch_providers, but discover only surfaces the + // include direction ("where can I watch this"). No toggle is rendered. + exclude: false, + }, + language: { include: true, exclude: true }, // post-filtered on original_language + country: { + include: true, // TMDB: with_origin_country + exclude: true, // no without_origin_country; excluded via complement query + }, + // Movies have no status field on the TMDB discover endpoint — the section + // is hidden entirely (both flags false). + status: { include: false, exclude: false }, + }, + tv: { + genres: { include: true, exclude: true }, + keywords: { include: true, exclude: true }, + // TV has no studio dimension (it uses networks, which discover does not + // surface) — the section is hidden entirely. + studio: { include: false, exclude: false }, + watchProviders: { + include: true, + exclude: false, + }, + language: { include: true, exclude: true }, + country: { + include: true, // TMDB: with_origin_country + exclude: true, // post-filtered on origin_country (present on TV list items) + }, + status: { + include: true, // TMDB: with_status + exclude: true, // no without_status; excluded via complement query + }, + }, +}; diff --git a/shared/discover/schema.ts b/shared/discover/schema.ts new file mode 100644 index 0000000000..f755c105b8 --- /dev/null +++ b/shared/discover/schema.ts @@ -0,0 +1,158 @@ +/** + * Single source of truth for discover filter parsing. + * + * Imported by both `server/routes/discover.ts` and + * `src/components/Discover/constants.ts`. Parses paired flat URL params + * (`?genre=28&excludeGenres=27`) into a structured {@link DiscoverFilter} + * (`{ genres: { include: ['28'], exclude: ['27'] } }`). + * + * `z.coerce.string()` is used for server-side parsing (req.query values are + * string arrays); the same schema accepts plain strings on the client. + */ +import { z } from 'zod'; +import type { DiscoverFilter } from './types'; + +const split = (v?: string): string[] | undefined => { + const arr = v?.split(',').filter(Boolean); + return arr?.length ? arr : undefined; +}; + +const range = (gte?: string, lte?: string) => + gte || lte ? { gte, lte } : undefined; + +export const DiscoverFilterSchema = z + .object({ + // Paired dimensions + genre: z.coerce.string().optional(), + excludeGenres: z.coerce.string().optional(), + keywords: z.coerce.string().optional(), + excludeKeywords: z.coerce.string().optional(), + studio: z.coerce.string().optional(), + excludeStudio: z.coerce.string().optional(), + watchProviders: z.coerce.string().optional(), + excludeWatchProviders: z.coerce.string().optional(), + language: z.coerce.string().optional(), + excludeLanguages: z.coerce.string().optional(), + country: z.coerce.string().optional(), + excludeCountries: z.coerce.string().optional(), + status: z.coerce.string().optional(), + excludeStatus: z.coerce.string().optional(), + // Ranges + primaryReleaseDateGte: z.coerce.string().optional(), + primaryReleaseDateLte: z.coerce.string().optional(), + firstAirDateGte: z.coerce.string().optional(), + firstAirDateLte: z.coerce.string().optional(), + withRuntimeGte: z.coerce.string().optional(), + withRuntimeLte: z.coerce.string().optional(), + voteAverageGte: z.coerce.string().optional(), + voteAverageLte: z.coerce.string().optional(), + voteCountGte: z.coerce.string().optional(), + voteCountLte: z.coerce.string().optional(), + // Certification + certification: z.coerce.string().optional(), + certificationGte: z.coerce.string().optional(), + certificationLte: z.coerce.string().optional(), + certificationCountry: z.coerce.string().optional(), + // Client-only marker for which certification inputs to render + certificationMode: z.enum(['exact', 'range']).optional(), + // Scalars + sortBy: z.coerce.string().optional(), + page: z.coerce.number().optional(), + }) + .transform( + (raw): DiscoverFilter => ({ + genres: { include: split(raw.genre), exclude: split(raw.excludeGenres) }, + keywords: { + include: split(raw.keywords), + exclude: split(raw.excludeKeywords), + }, + studio: { + include: split(raw.studio), + exclude: split(raw.excludeStudio), + }, + watchProviders: { + include: split(raw.watchProviders), + exclude: split(raw.excludeWatchProviders), + }, + language: { + include: split(raw.language), + exclude: split(raw.excludeLanguages), + }, + country: { + include: split(raw.country), + exclude: split(raw.excludeCountries), + }, + status: { + include: split(raw.status), + exclude: split(raw.excludeStatus), + }, + primaryReleaseDate: range( + raw.primaryReleaseDateGte, + raw.primaryReleaseDateLte + ), + firstAirDate: range(raw.firstAirDateGte, raw.firstAirDateLte), + runtime: range(raw.withRuntimeGte, raw.withRuntimeLte), + voteAverage: range(raw.voteAverageGte, raw.voteAverageLte), + voteCount: range(raw.voteCountGte, raw.voteCountLte), + certification: + raw.certification || + raw.certificationGte || + raw.certificationLte || + raw.certificationCountry + ? { + value: raw.certification, + gte: raw.certificationGte, + lte: raw.certificationLte, + country: raw.certificationCountry, + } + : undefined, + sortBy: raw.sortBy, + page: raw.page, + }) + ); + +/** + * Flat representation derived from the structured filter. Used by client-side + * helpers that still operate on flat URL params (e.g. `prepareFilterValues`, + * `countActiveFilters`). Kept in sync with the schema's flat input shape. + */ +export const FlatFilterKeys = [ + 'sortBy', + 'primaryReleaseDateGte', + 'primaryReleaseDateLte', + 'firstAirDateGte', + 'firstAirDateLte', + 'studio', + 'excludeStudio', + 'genre', + 'excludeGenres', + 'keywords', + 'excludeKeywords', + 'language', + 'excludeLanguages', + 'watchProviders', + 'excludeWatchProviders', + 'country', + 'excludeCountries', + 'status', + 'excludeStatus', + 'withRuntimeGte', + 'withRuntimeLte', + 'voteAverageGte', + 'voteAverageLte', + 'voteCountGte', + 'voteCountLte', + 'watchRegion', + 'certification', + 'certificationGte', + 'certificationLte', + 'certificationCountry', + 'certificationMode', +] as const; + +export type FlatFilterKey = (typeof FlatFilterKeys)[number]; + +export type FlatFilterValues = Partial>; + +export const isFlatFilterKey = (k: string): k is FlatFilterKey => + (FlatFilterKeys as readonly string[]).includes(k); diff --git a/shared/discover/types.ts b/shared/discover/types.ts new file mode 100644 index 0000000000..a9ac0fd283 --- /dev/null +++ b/shared/discover/types.ts @@ -0,0 +1,104 @@ +/** + * Shared discover domain types. + * + * Imported by both server and client. This module imports nothing — it is pure + * type definitions, which is what makes it safe to share across the + * browser/server boundary. + */ + +/** A single filter dimension that supports paired include/exclude lists. */ +export interface DimensionFilter { + include?: T[]; + exclude?: T[]; +} + +/** + * Structured discover filter. + * + * Paired dimensions collapse the flat URL params (`genre` + `excludeGenres`) + * into one object (`genres: { include, exclude }`). Range filters stay flat + * because a range is inherently single-mode. + */ +export interface DiscoverFilter { + genres: DimensionFilter; + keywords: DimensionFilter; + studio: DimensionFilter; // movie + watchProviders: DimensionFilter; + language: DimensionFilter; + country: DimensionFilter; + status: DimensionFilter; // tv + // Ranges — single-mode, kept flat + primaryReleaseDate?: { gte?: string; lte?: string }; // movie + firstAirDate?: { gte?: string; lte?: string }; // tv + runtime?: { gte?: string; lte?: string }; + voteAverage?: { gte?: string; lte?: string }; + voteCount?: { gte?: string; lte?: string }; + certification?: { + value?: string; + gte?: string; + lte?: string; + country?: string; + }; + // Scalars + sortBy?: string; + page?: number; +} + +/** + * Dimension keys that have paired include/exclude semantics. + * + * Each key maps 1:1 to a filter section in the discover slideover. A dimension + * that is not surfaced in the UI (e.g. network) must not appear here. + */ +export type DimensionKey = + | 'genres' + | 'keywords' + | 'studio' + | 'watchProviders' + | 'language' + | 'country' + | 'status'; + +/** Params to forward to the TMDB `/discover/*` endpoint. */ +export type TmdbDiscoverParams = Record< + string, + string | number | boolean | undefined +>; + +/** Post-filter rules applied locally after TMDB returns. */ +export interface PostFilterSpec { + excludeLanguages?: string[]; + excludeCountries?: string[]; +} + +/** + * Execution plan for a discover request. The plan builder translates a + * structured filter into wrapper-level options + post-filter rules; the route + * handler just executes the plan. + */ +export interface DiscoverPlan { + /** Options spread into the TMDB wrapper's getDiscoverMovies/getDiscoverTv. */ + discoverOptions: Record; + /** Rules applied locally after TMDB returns (fields TMDB can't filter natively). */ + postFilter: PostFilterSpec; +} + +/** Minimum shape a list item needs to be post-filterable. */ +export interface ListResult { + original_language?: string; + origin_country?: string[]; +} + +/** + * Discover response that tells the truth about pagination. + * + * When `paginationIsEstimate` is true, `totalResults` is TMDB's pre-filter + * count and the UI should display it as an estimate. + */ +export interface HonestPage { + page: number; + totalPages: number; + totalResults: number; + results: T[]; + paginationIsEstimate: boolean; +} diff --git a/src/components/Common/SlideCheckbox/index.tsx b/src/components/Common/SlideCheckbox/index.tsx index 49ce4391af..c7e4c34420 100644 --- a/src/components/Common/SlideCheckbox/index.tsx +++ b/src/components/Common/SlideCheckbox/index.tsx @@ -8,7 +8,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => { { onClick(); }} @@ -17,7 +17,7 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => { onClick(); } }} - className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none`} + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center focus:outline-none`} >