diff --git a/docs/using-seerr/settings/general.md b/docs/using-seerr/settings/general.md index 65b337495f..e0b4166955 100644 --- a/docs/using-seerr/settings/general.md +++ b/docs/using-seerr/settings/general.md @@ -40,6 +40,13 @@ Set the default display language for Seerr. Users can override this setting in t These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings. +The Discover page also exposes a filter panel where each dimension can be toggled between **Included** and **Excluded**: + +- **Included** narrows results to items matching the selected values (for example, only movies in Spanish, or only productions from Mexico). +- **Excluded** removes items matching the selected values (for example, hiding Hindi-language movies or productions from India). + +Streaming services currently support inclusion only in the Discover filter UI. (The underlying TMDB API exposes a provider-exclusion parameter, but it is not surfaced in the user interface because its practical value is low.) + ## Blocklist Region and Blocklist Language These settings control the region and language used specifically for blocklist content scanning. The "Process Blocklisted Tags" job uses these settings to determine which content to scan for blocklisting, independent of the general Discover settings. diff --git a/seerr-api.yml b/seerr-api.yml index 18f3361d6a..488adc4d5b 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -5326,6 +5326,10 @@ paths: totalResults: type: number example: 200 + paginationIsEstimate: + type: boolean + example: false + description: True when totalResults/totalPages are estimates because exclude post-filtering required over-fetching results: type: array items: @@ -5538,6 +5542,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 @@ -5555,6 +5595,10 @@ paths: totalResults: type: number example: 200 + paginationIsEstimate: + type: boolean + example: false + description: True when totalResults/totalPages are estimates because exclude post-filtering required over-fetching results: type: array items: @@ -5869,6 +5913,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 @@ -5886,6 +5966,10 @@ paths: totalResults: type: number example: 200 + paginationIsEstimate: + type: boolean + example: false + description: True when totalResults/totalPages are estimates because exclude post-filtering required over-fetching results: type: array items: 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..e677933c19 --- /dev/null +++ b/server/discover/fill.ts @@ -0,0 +1,73 @@ +import { PAGE_SIZE } from '@server/discover/planBuilder'; +import { applyPostFilter } from '@server/discover/postFilter'; +import type { HonestPage, ListResult, PostFilterSpec } from './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..b0bbe64cb5 --- /dev/null +++ b/server/discover/planBuilder.ts @@ -0,0 +1,157 @@ +import type { SortOptions } from '@server/api/themoviedb'; +import { buildComplement } from '@server/discover/countryCodes'; +import type { + DiscoverFilter, + DiscoverPlan, + PostFilterSpec, + TmdbDiscoverMovieParams, + TmdbDiscoverTvParams, +} from './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 = + mediaType === 'movie' + ? ({} as TmdbDiscoverMovieParams) + : ({} as TmdbDiscoverTvParams); + 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 as SortOptions; + } + + // 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') { + const movieOptions = discoverOptions as TmdbDiscoverMovieParams; + movieOptions.studio = join(dim(filter.studio).include); + if (dim(filter.studio).exclude?.length) + movieOptions.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') { + const movieOptions = discoverOptions as TmdbDiscoverMovieParams; + if (dim(filter.country).exclude?.length) { + movieOptions.originCountryParam = buildComplement( + allCountryCodes, + filter.country!.exclude! + ); + } else if (dim(filter.country).include?.length) { + movieOptions.originCountryParam = filter.country!.include!.join('|'); + } + } else { + const tvOptions = discoverOptions as TmdbDiscoverTvParams; + if (dim(filter.country).include?.length) + tvOptions.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') { + const tvOptions = discoverOptions as TmdbDiscoverTvParams; + if (dim(filter.status).exclude?.length) { + tvOptions.withStatus = buildComplement( + ALL_TV_STATUS, + filter.status!.exclude! + ); + } else if (dim(filter.status).include?.length) { + tvOptions.withStatus = filter.status!.include!.join('|'); + } + } + + // ── Ranges ── + if (mediaType === 'movie' && filter.primaryReleaseDate) { + const movieOptions = discoverOptions as TmdbDiscoverMovieParams; + movieOptions.primaryReleaseDateGte = normalizeDate( + filter.primaryReleaseDate.gte + ); + movieOptions.primaryReleaseDateLte = normalizeDate( + filter.primaryReleaseDate.lte + ); + } + if (mediaType === 'tv' && filter.firstAirDate) { + const tvOptions = discoverOptions as TmdbDiscoverTvParams; + tvOptions.firstAirDateGte = normalizeDate(filter.firstAirDate.gte); + tvOptions.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..64640b5290 --- /dev/null +++ b/server/discover/postFilter.ts @@ -0,0 +1,41 @@ +import type { ListResult, PostFilterSpec } from './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/discover/schema.ts b/server/discover/schema.ts new file mode 100644 index 0000000000..29a2e4e514 --- /dev/null +++ b/server/discover/schema.ts @@ -0,0 +1,116 @@ +/** + * Discover filter parsing. + * + * The query schema is built from {@link DISCOVER_DIMENSIONS} so the flat + * URL-param keys cannot drift from the structured type. Both the client + * (flat `FilterOptions` shape, via `@server/discover/schema`) and the server + * (structured `DiscoverFilter` via {@link DiscoverFilterSchema}) import it. + * + * `z.coerce.string()` accepts both Express `req.query` values (string | + * string[]) and plain router strings, so one schema serves both consumers. + */ +import { z } from 'zod'; +import { + DISCOVER_DIMENSIONS, + type DimensionFilter, + type DimensionFlatKey, + type DimensionKey, + type DiscoverFilter, +} from './types'; + +const split = (v?: string): string[] | undefined => { + // Seerr selectors use a mix of separators: LanguageSelector and + // StatusSelector emit pipe-separated values (shared with user settings), + // while GenreSelector, KeywordSelector, CountrySelector, etc. use commas. + // Accepting both keeps the URL contract compatible with the existing + // components instead of forcing a migration to a single separator. + const arr = v?.split(/[,|]/).filter(Boolean); + return arr?.length ? arr : undefined; +}; + +const range = (gte?: string, lte?: string) => + gte || lte ? { gte, lte } : undefined; + +// Build the dimension shape directly from the registry so the flat keys stay +// in lockstep with the structured type. `Object.fromEntries` widens keys, so +// we cast back to the exact literal union. +const dimensionShape = Object.fromEntries( + ( + Object.entries(DISCOVER_DIMENSIONS) as [ + DimensionKey, + (typeof DISCOVER_DIMENSIONS)[DimensionKey], + ][] + ).flatMap(([, { includeKey, excludeKey }]) => [ + [includeKey, z.coerce.string().optional()], + [excludeKey, z.coerce.string().optional()], + ]) +) as Record>; + +/** + * Flat URL-param schema. The single source of truth for which query params + * discover accepts. Imported by the client as `QueryFilterOptions` and by the + * server as the base of {@link DiscoverFilterSchema}. + */ +export const DiscoverFilterQuerySchema = z.object({ + ...dimensionShape, + // 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(), + certificationMode: z.enum(['exact', 'range']).optional(), + // Watch providers + watchRegion: z.coerce.string().optional(), + // Scalars + sortBy: z.coerce.string().optional(), +}); + +/** Server-side transform: flat params → structured, plan-ready filter. */ +export const DiscoverFilterSchema = DiscoverFilterQuerySchema.transform( + (raw): DiscoverFilter => { + const dimensions = {} as Record; + for (const [dim, { includeKey, excludeKey }] of Object.entries( + DISCOVER_DIMENSIONS + ) as [DimensionKey, (typeof DISCOVER_DIMENSIONS)[DimensionKey]][]) { + dimensions[dim] = { + include: split(raw[includeKey]), + exclude: split(raw[excludeKey]), + }; + } + return { + ...dimensions, + 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, + }; + } +); diff --git a/server/discover/types.ts b/server/discover/types.ts new file mode 100644 index 0000000000..4f80a96577 --- /dev/null +++ b/server/discover/types.ts @@ -0,0 +1,170 @@ +/** + * Single source of truth for discover filter dimensions and their derived + * shapes. + * + * The {@link DISCOVER_DIMENSIONS} registry is the only place dimension names + * are written. Everything else — the {@link DimensionKey} union, the + * {@link DiscoverFilter} type, the query schema, the capabilities matrix — is + * derived from it. Adding a dimension here is a compile error in every + * consumer until it is wired up; removing one removes it everywhere. + * + * Imported by both server (route handlers, plan builder) and client (the + * discover slideover), via the existing `@server/*` alias. + */ + +import type { SortOptions } from '@server/api/themoviedb'; + +/** Paired include/exclude URL-param keys for one dimension. */ +interface DimensionKeys { + includeKey: string; + excludeKey: string; +} + +/** + * The dimension registry. Each entry maps a structured dimension key to its + * two flat URL-param names. The plural/singular asymmetry (e.g. `genre` but + * `keywords`) reflects the existing URL contract and cannot be changed without + * a breaking param rename. + */ +export const DISCOVER_DIMENSIONS = { + genres: { includeKey: 'genre', excludeKey: 'excludeGenres' }, + keywords: { includeKey: 'keywords', excludeKey: 'excludeKeywords' }, + studio: { includeKey: 'studio', excludeKey: 'excludeStudio' }, + watchProviders: { + includeKey: 'watchProviders', + excludeKey: 'excludeWatchProviders', + }, + language: { includeKey: 'language', excludeKey: 'excludeLanguages' }, + country: { includeKey: 'country', excludeKey: 'excludeCountries' }, + status: { includeKey: 'status', excludeKey: 'excludeStatus' }, +} as const satisfies Record; + +/** Structured dimension key — derived from the registry. */ +export type DimensionKey = keyof typeof DISCOVER_DIMENSIONS; + +/** Flat URL-param key belonging to any dimension — derived from the registry. */ +export type DimensionFlatKey = (typeof DISCOVER_DIMENSIONS)[DimensionKey][ + | 'includeKey' + | 'excludeKey']; + +/** A single 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 }`). The dimension keys are + * mapped over {@link DimensionKey}, so every registry entry must appear. + * Range and scalar fields stay flat because a range is inherently single-mode. + */ +export type DiscoverFilter = { + [K in DimensionKey]: DimensionFilter; +} & { + primaryReleaseDate?: { gte?: string; lte?: string }; + firstAirDate?: { gte?: string; lte?: string }; + runtime?: { gte?: string; lte?: string }; + voteAverage?: { gte?: string; lte?: string }; + voteCount?: { gte?: string; lte?: string }; + certification?: { + value?: string; + gte?: string; + lte?: string; + country?: string; + }; + sortBy?: string; +}; + +/** Params to forward to the TMDB `/discover/movie` endpoint. */ +export interface TmdbDiscoverMovieParams { + sortBy?: SortOptions; + primaryReleaseDateGte?: string; + primaryReleaseDateLte?: string; + withRuntimeGte?: string; + withRuntimeLte?: string; + voteAverageGte?: string; + voteAverageLte?: string; + voteCountGte?: string; + voteCountLte?: string; + originalLanguage?: string; + genre?: string; + excludeGenres?: string; + keywords?: string; + excludeKeywords?: string; + studio?: string; + excludeStudio?: string; + watchProviders?: string; + excludeWatchProviders?: string; + certification?: string; + certificationGte?: string; + certificationLte?: string; + certificationCountry?: string; + originCountryParam?: string; +} + +/** Params to forward to the TMDB `/discover/tv` endpoint. */ +export interface TmdbDiscoverTvParams { + sortBy?: SortOptions; + firstAirDateGte?: string; + firstAirDateLte?: string; + withRuntimeGte?: string; + withRuntimeLte?: string; + voteAverageGte?: string; + voteAverageLte?: string; + voteCountGte?: string; + voteCountLte?: string; + originalLanguage?: string; + genre?: string; + excludeGenres?: string; + keywords?: string; + excludeKeywords?: string; + watchProviders?: string; + excludeWatchProviders?: string; + withStatus?: string; + certification?: string; + certificationGte?: string; + certificationLte?: string; + certificationCountry?: string; + country?: string; +} + +/** 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: TmdbDiscoverMovieParams | TmdbDiscoverTvParams; + /** 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/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..b4a50484c9 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,9 +1,12 @@ 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 { DiscoverFilterSchema } from '@server/discover/schema'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; @@ -24,7 +27,6 @@ import { mapNetwork } from '@server/models/Tv'; import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers'; import { Router } from 'express'; import { sortBy } from 'lodash'; -import { z } from 'zod'; export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -49,6 +51,42 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => { }); }; +/** + * Resolve the `server` and `all` language shorthands to concrete ISO codes. + * Mirrors the logic in createTmdbWithRegionLanguage but returns the list of + * codes instead of joining them, so callers can use the same resolution for + * include and exclude filters. + */ +export const resolveOriginalLanguageCodes = (user?: User): string[] => { + const settings = getSettings(); + const raw = + user?.settings?.originalLanguage === 'all' + ? '' + : user?.settings?.originalLanguage + ? user?.settings?.originalLanguage + : settings.main.originalLanguage; + + if (!raw) { + return []; + } + + return raw + .split('|') + .map((code) => code.trim()) + .filter(Boolean); +}; + +export const resolveServerLanguage = ( + codes: string[], + user?: User +): string[] => { + const resolved = codes.map((code) => + code === 'server' ? resolveOriginalLanguageCodes(user) : [code] + ); + + return resolved.flat(); +}; + export const createTmdbWithBlocklistSettings = (): TheMovieDb => { const settings = getSettings(); @@ -60,111 +98,89 @@ 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 filter = DiscoverFilterSchema.parse(req.query); + + // Resolve language shorthand `server` to the configured ISO codes so + // include/exclude behave symmetrically. `all` or empty settings resolve + // to no codes, which means no filter is applied. + if (filter.language.include) { + filter.language.include = resolveServerLanguage( + filter.language.include, + req.user + ); + } + if (filter.language.exclude) { + filter.language.exclude = resolveServerLanguage( + filter.language.exclude, + req.user + ); + } - 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, - }); + // Country codes are only needed when the request actually filters by + // country, so avoid the extra TMDB call in the common case. + const needsCountryCodes = + filter.country.include?.length || filter.country.exclude?.length; + const countryCodes = needsCountryCodes + ? await getAllCountryCodes(tmdb) + : []; + const plan = buildDiscoverPlan(filter, 'movie', countryCodes); + + const page = await fillPage( + (p) => + tmdb.getDiscoverMovies({ + ...plan.discoverOptions, + page: p, + language: req.locale, + watchRegion: req.query.watchRegion as string | undefined, + }), + plan.postFilter, + Number(req.query.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.allSettled( + keywordIds.map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) + ) + ) + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + .map((result) => result.value) + .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 +422,85 @@ 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); + + // Resolve language shorthand `server` to the configured ISO codes so + // include/exclude behave symmetrically. `all` or empty settings resolve + // to no codes, which means no filter is applied. + if (filter.language.include) { + filter.language.include = resolveServerLanguage( + filter.language.include, + req.user + ); + } + if (filter.language.exclude) { + filter.language.exclude = resolveServerLanguage( + filter.language.exclude, + req.user + ); + } + + // Country codes are only needed when the request actually filters by + // country, so avoid the extra TMDB call in the common case. + const needsCountryCodes = + filter.country.include?.length || filter.country.exclude?.length; + const countryCodes = needsCountryCodes + ? await getAllCountryCodes(tmdb) + : []; + const plan = buildDiscoverPlan(filter, 'tv', countryCodes); + + const page = await fillPage( + (p) => + tmdb.getDiscoverTv({ + ...plan.discoverOptions, + page: p, + language: req.locale, + watchRegion: req.query.watchRegion as string | undefined, + }), + plan.postFilter, + Number(req.query.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.allSettled( + keywordIds.map((keywordId) => + tmdb.getKeywordDetails({ keywordId: Number(keywordId) }) + ) + ) + ) + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled' + ) + .map((result) => result.value) + .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/src/components/Common/SlideCheckbox/index.tsx b/src/components/Common/SlideCheckbox/index.tsx index 49ce4391af..4fb72b6a8d 100644 --- a/src/components/Common/SlideCheckbox/index.tsx +++ b/src/components/Common/SlideCheckbox/index.tsx @@ -8,16 +8,17 @@ const SlideCheckbox = ({ onClick, checked = false }: SlideCheckboxProps) => { { onClick(); }} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); 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`} >