Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/using-seerr/settings/general.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, as TMDB does not expose a user-facing exclusion parameter for watch providers.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

## 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.
Expand Down
72 changes: 72 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions server/api/themoviedb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,11 @@ interface DiscoverMovieOptions {
certificationGte?: string;
certificationLte?: string;
certificationCountry?: string;
excludeGenres?: string;
excludeStudio?: string;
excludeWatchProviders?: string;
country?: string;
originCountryParam?: string;
}

interface DiscoverTvOptions {
Expand Down Expand Up @@ -122,6 +127,9 @@ interface DiscoverTvOptions {
certificationGte?: string;
certificationLte?: string;
certificationCountry?: string;
excludeGenres?: string;
excludeWatchProviders?: string;
country?: string;
}

class TheMovieDb extends ExternalAPI implements TvShowProvider {
Expand Down Expand Up @@ -607,6 +615,11 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
certificationGte,
certificationLte,
certificationCountry,
excludeGenres,
excludeStudio,
excludeWatchProviders,
country,
originCountryParam,
}: DiscoverMovieOptions = {}): Promise<TmdbSearchMovieResponse> => {
try {
const defaultFutureDate = new Date(
Expand Down Expand Up @@ -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,
},
});

Expand Down Expand Up @@ -695,6 +712,9 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider {
certificationGte,
certificationLte,
certificationCountry,
excludeGenres,
excludeWatchProviders,
country,
}: DiscoverTvOptions = {}): Promise<TmdbSearchTvResponse> => {
try {
const defaultFutureDate = new Date(
Expand Down Expand Up @@ -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,
},
});

Expand Down
24 changes: 24 additions & 0 deletions server/discover/countryCodes.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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;
}
73 changes: 73 additions & 0 deletions server/discover/fill.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
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<T extends ListResult>(
fetch: (page: number) => Promise<TmdbPage<T>>,
postFilter: PostFilterSpec,
requestedPage: number,
maxOverFetch = 3
): Promise<HonestPage<T>> {
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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}
Loading