Skip to content
This repository was archived by the owner on Feb 15, 2026. It is now read-only.
27 changes: 27 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4547,6 +4547,11 @@ paths:
schema:
type: string
example: en
- in: query
name: status
schema:
type: string
example: 2
- in: query
name: genre
schema:
Expand Down Expand Up @@ -4969,6 +4974,28 @@ paths:
name:
type: string
example: Genre Name
/discover/status/tv:
get:
summary: Get statuses for TV series
description: Returns a list of statuses
tags:
- search
responses:
'200':
description: Status data returned
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: number
example: 1
name:
type: string
example: Status Name
/discover/watchlist:
get:
summary: Get the Plex watchlist.
Expand Down
3 changes: 3 additions & 0 deletions server/api/themoviedb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ interface DiscoverMovieOptions {
interface DiscoverTvOptions {
page?: number;
language?: string;
status?: string;
firstAirDateGte?: string;
firstAirDateLte?: string;
withRuntimeGte?: string;
Expand Down Expand Up @@ -529,6 +530,7 @@ class TheMovieDb extends ExternalAPI {
sortBy = 'popularity.desc',
page = 1,
language = 'en',
status,
firstAirDateGte,
firstAirDateLte,
includeEmptyReleaseDate = false,
Expand Down Expand Up @@ -561,6 +563,7 @@ class TheMovieDb extends ExternalAPI {
sort_by: sortBy,
page,
language,
with_status: status,
region: this.region,
// Set our release date values, but check if one is set and not the other,
// so we can force a past date or a future date. TMDB Requires both values if one is set!
Expand Down
5 changes: 5 additions & 0 deletions server/interfaces/api/discoverInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ export interface GenreSliderItem {
backdrops: string[];
}

export interface StatusItem {
id: number;
name: string;
}

export interface WatchlistItem {
ratingKey: string;
tmdbId: number;
Expand Down
39 changes: 39 additions & 0 deletions server/routes/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type {
GenreSliderItem,
StatusItem,
WatchlistResponse,
} from '@server/interfaces/api/discoverInterfaces';
import { getSettings } from '@server/lib/settings';
Expand Down Expand Up @@ -61,6 +62,7 @@ const QueryFilterOptions = z.object({
genre: z.coerce.string().optional(),
keywords: z.coerce.string().optional(),
language: z.coerce.string().optional(),
status: z.coerce.string().optional(),
withRuntimeGte: z.coerce.string().optional(),
withRuntimeLte: z.coerce.string().optional(),
voteAverageGte: z.coerce.string().optional(),
Expand Down Expand Up @@ -361,6 +363,7 @@ discoverRoutes.get('/tv', async (req, res, next) => {
page: Number(query.page),
sortBy: query.sortBy as SortOptions,
language: req.locale ?? query.language,
status: query.status,
genre: query.genre,
network: query.network ? Number(query.network) : undefined,
firstAirDateLte: query.firstAirDateLte
Expand Down Expand Up @@ -809,6 +812,42 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>(
}
);

enum ShowStatus {
'Returning Series' = 0,
'Planned' = 1,
'In Production' = 2,
'Ended' = 3,
'Canceled' = 4,
'Pilot' = 5,
}

discoverRoutes.get<{ language: string }, StatusItem[]>(
'/status/tv',
async (req, res, next) => {
try {
const statuses = Object.entries(ShowStatus)
.filter(([, v]) => !isNaN(Number(v)))
.map(([k, v]) => ({
id: Number(v),
name: k,
}));

const sortedData = sortBy(statuses, 'id');

return res.status(200).json(sortedData);
} catch (e) {
logger.debug('Something went wrong retrieving the series status', {
label: 'API',
errorMessage: e.message,
});
return next({
status: 500,
message: 'Unable to retrieve series status.',
});
}
}
);

discoverRoutes.get<Record<string, unknown>, WatchlistResponse>(
'/watchlist',
async (req, res) => {
Expand Down
16 changes: 16 additions & 0 deletions src/components/Discover/FilterSlideover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CompanySelector,
GenreSelector,
KeywordSelector,
StatusSelector,
WatchProviderSelector,
} from '@app/components/Selector';
import useSettings from '@app/hooks/useSettings';
Expand Down Expand Up @@ -37,6 +38,7 @@ const messages = defineMessages({
tmdbuserscore: 'TMDB User Score',
tmdbuservotecount: 'TMDB User Vote Count',
runtime: 'Runtime',
status: 'Status',
streamingservices: 'Streaming Services',
voteCount: 'Number of votes between {minValue} and {maxValue}',
});
Expand Down Expand Up @@ -149,6 +151,20 @@ const FilterSlideover = ({
updateQueryParams('genre', value?.map((v) => v.value).join(','));
}}
/>
{type === 'tv' && (
<>
<span className="text-lg font-semibold">
{intl.formatMessage(messages.status)}
</span>
<StatusSelector
onChange={(value) => {
updateQueryParams('status', value?.value.toString());
}}
type={type}
defaultValue={currentFilters.status}
/>
</>
)}
<span className="text-lg font-semibold">
{intl.formatMessage(messages.keywords)}
</span>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Discover/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export const QueryFilterOptions = z.object({
genre: z.string().optional(),
keywords: z.string().optional(),
language: z.string().optional(),
status: z.string().optional(),
withRuntimeGte: z.string().optional(),
withRuntimeLte: z.string().optional(),
voteAverageGte: z.string().optional(),
Expand Down Expand Up @@ -155,6 +156,10 @@ export const prepareFilterValues = (
filterValues.language = values.language;
}

if (values.status) {
filterValues.status = values.status;
}

if (values.withRuntimeGte) {
filterValues.withRuntimeGte = values.withRuntimeGte;
}
Expand Down
79 changes: 77 additions & 2 deletions src/components/Selector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ import type {
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type {
GenreSliderItem,
StatusItem,
} from '@server/interfaces/api/discoverInterfaces';
import type {
Keyword,
ProductionCompany,
WatchProviderDetails,
} from '@server/models/common';
import axios from 'axios';
import { orderBy } from 'lodash';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { MultiValue, SingleValue } from 'react-select';
import AsyncSelect from 'react-select/async';
Expand All @@ -28,6 +31,7 @@ import useSWR from 'swr';
const messages = defineMessages({
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStatus: 'Select status...',
searchStudios: 'Search studios…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
Expand Down Expand Up @@ -279,6 +283,77 @@ export const KeywordSelector = ({
);
};

type StatusSelectorProps = BaseSelectorSingleProps & {
type: 'movie' | 'tv';
};

export const StatusSelector = ({
isMulti,
onChange,
defaultValue,
type,
}: StatusSelectorProps) => {
const intl = useIntl();
const [defaultDataValue, setDefaultDataValue] = useState<
{ label: string; value: number }[] | null
>(null);

const loadStatusOptions = useCallback(
async (inputValue?: string) => {
const results = await axios.get<StatusItem[]>(
`/api/v1/discover/status/${type}`
);

const res = results.data
.map((result) => ({
label: result.name,
value: result.id,
}))
.filter(({ label }) =>
inputValue
? label.toLowerCase().includes(inputValue.toLowerCase())
: true
);

return res;
},
[type]
);

useEffect(() => {
const setDefault = () => {
loadStatusOptions().then((res) => {
const foundDefaultValue = res.find(
({ value }) => value.toString() === defaultValue
);
if (foundDefaultValue) {
setDefaultDataValue([foundDefaultValue]);
}
});
};
setDefault();
}, [defaultValue, loadStatusOptions]);

return (
<AsyncSelect
key={`status-select-${defaultDataValue}`}
className="react-select-container"
classNamePrefix="react-select"
defaultValue={isMulti ? defaultValue : defaultDataValue?.[0]}
defaultOptions
cacheOptions
isMulti={isMulti}
loadOptions={loadStatusOptions}
placeholder={intl.formatMessage(messages.searchStatus)}
onChange={(value) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange(value as any);
}}
isClearable
/>
);
};

type WatchProviderSelectorProps = {
type: 'movie' | 'tv';
region?: string;
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"components.Discover.FilterSlideover.releaseDate": "Release Date",
"components.Discover.FilterSlideover.runtime": "Runtime",
"components.Discover.FilterSlideover.runtimeText": "{minValue}-{maxValue} minute runtime",
"components.Discover.FilterSlideover.status": "Status",
"components.Discover.FilterSlideover.streamingservices": "Streaming Services",
"components.Discover.FilterSlideover.studio": "Studio",
"components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score",
Expand Down Expand Up @@ -515,6 +516,7 @@
"components.Selector.nooptions": "No results.",
"components.Selector.searchGenres": "Select genres…",
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
Expand Down