From 9d7d6cbf6dbc009f592b0f84e49fbb091c726c99 Mon Sep 17 00:00:00 2001 From: demigodmode Date: Sun, 21 Jun 2026 12:41:20 -0500 Subject: [PATCH] feat(discover): filter movie discovery by minimum release Adds a Minimum Release filter to movie discover. Picking a level matches that release type or later, so Digital also covers physical and TV. with_release_type is a no-op on TMDB unless paired with a release_date range, so when it's set the range switches to release_date and the "To" date defaults to today. re #579 --- seerr-api.yml | 13 +++++ server/api/themoviedb/index.ts | 22 +++++++- server/routes/discover.ts | 2 + .../Discover/FilterSlideover/index.tsx | 50 +++++++++++++++++++ src/components/Discover/constants.ts | 5 ++ src/i18n/locale/en.json | 7 +++ 6 files changed, 97 insertions(+), 2 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 4b33bf7892..f596eaa236 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -5595,6 +5595,19 @@ paths: schema: type: string example: 2023-01-01 + - in: query + name: releaseType + schema: + type: string + enum: ['2', '3', '4', '5', '6'] + example: '4' + description: >- + Minimum release availability for movie discovery. The selected type + and everything above it are matched (e.g. 4 = digital also matches 5 + = physical and 6 = TV, sent to TMDB as with_release_type=4|5|6). + When set, results are filtered on release_date instead of + primary_release_date. 2 = limited theatrical, 3 = theatrical, + 4 = digital, 5 = physical, 6 = TV - in: query name: withRuntimeGte schema: diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 305d6048d7..846b358497 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -89,6 +89,7 @@ interface DiscoverMovieOptions { keywords?: string; excludeKeywords?: string; sortBy?: SortOptions; + releaseType?: string; watchRegion?: string; watchProviders?: string; certification?: string; @@ -601,6 +602,7 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { voteAverageLte, voteCountGte, voteCountLte, + releaseType, watchProviders, watchRegion, certification, @@ -619,6 +621,21 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { .toISOString() .split('T')[0]; + // "Minimum availability" is cumulative: picking Digital (4) should also + // match Physical (5) and TV (6), so expand the selected type up to 6. + // with_release_type is a no-op on TMDB unless it's paired with a + // release_date range, so when it's set we filter on release_date instead + // of primary_release_date (the field the plain Release Date filter uses). + const releaseTypeFilter = releaseType + ? Array.from( + { length: 6 - Number(releaseType) + 1 }, + (_, i) => Number(releaseType) + i + ).join('|') + : undefined; + const releaseDateField = releaseType + ? 'release_date' + : 'primary_release_date'; + const data = await this.get('/discover/movie', { params: { sort_by: sortBy, @@ -635,14 +652,15 @@ class TheMovieDb extends ExternalAPI implements TvShowProvider { : this.originalLanguage, // 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! - 'primary_release_date.gte': + [`${releaseDateField}.gte`]: !primaryReleaseDateGte && primaryReleaseDateLte ? defaultPastDate : primaryReleaseDateGte, - 'primary_release_date.lte': + [`${releaseDateField}.lte`]: !primaryReleaseDateLte && primaryReleaseDateGte ? defaultFutureDate : primaryReleaseDateLte, + with_release_type: releaseTypeFilter, with_genres: genre, with_companies: studio, with_keywords: keywords, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..8d130dd7f2 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -65,6 +65,7 @@ const QueryFilterOptions = z.object({ sortBy: z.coerce.string().optional(), primaryReleaseDateGte: z.coerce.string().optional(), primaryReleaseDateLte: z.coerce.string().optional(), + releaseType: z.enum(['2', '3', '4', '5', '6']).optional(), firstAirDateGte: z.coerce.string().optional(), firstAirDateLte: z.coerce.string().optional(), studio: z.coerce.string().optional(), @@ -115,6 +116,7 @@ discoverRoutes.get('/movies', async (req, res, next) => { primaryReleaseDateGte: query.primaryReleaseDateGte ? new Date(query.primaryReleaseDateGte).toISOString().split('T')[0] : undefined, + releaseType: query.releaseType, keywords, excludeKeywords, withRuntimeGte: query.withRuntimeGte, diff --git a/src/components/Discover/FilterSlideover/index.tsx b/src/components/Discover/FilterSlideover/index.tsx index a20cb50142..cd74af66c5 100644 --- a/src/components/Discover/FilterSlideover/index.tsx +++ b/src/components/Discover/FilterSlideover/index.tsx @@ -27,6 +27,13 @@ const messages = defineMessages('components.Discover.FilterSlideover', { activefilters: '{count, plural, one {# Active Filter} other {# Active Filters}}', releaseDate: 'Release Date', + minimumRelease: 'Minimum Release', + anyRelease: 'Any', + limitedTheatrical: 'Theatrical (Limited)', + theatrical: 'Theatrical', + digital: 'Digital', + physical: 'Physical', + tvRelease: 'TV', firstAirDate: 'First Air Date', from: 'From', to: 'To', @@ -133,6 +140,49 @@ const FilterSlideover = ({ {type === 'movie' && ( <> + + {intl.formatMessage(messages.minimumRelease)} + + {intl.formatMessage(messages.studio)} diff --git a/src/components/Discover/constants.ts b/src/components/Discover/constants.ts index 4ce5e34f66..4b4d9961a0 100644 --- a/src/components/Discover/constants.ts +++ b/src/components/Discover/constants.ts @@ -94,6 +94,7 @@ export const QueryFilterOptions = z.object({ sortBy: z.string().optional(), primaryReleaseDateGte: z.string().optional(), primaryReleaseDateLte: z.string().optional(), + releaseType: z.enum(['2', '3', '4', '5', '6']).optional(), firstAirDateGte: z.string().optional(), firstAirDateLte: z.string().optional(), studio: z.string().optional(), @@ -138,6 +139,10 @@ export const prepareFilterValues = ( filterValues.primaryReleaseDateLte = values.primaryReleaseDateLte; } + if (values.releaseType) { + filterValues.releaseType = values.releaseType; + } + if (values.firstAirDateGte) { filterValues.firstAirDateGte = values.firstAirDateGte; } diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index b11bfc2de3..9dbc7c9742 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -82,15 +82,20 @@ "components.Discover.DiscoverWatchlist.discoverwatchlist": "Your Watchlist", "components.Discover.DiscoverWatchlist.watchlist": "Plex Watchlist", "components.Discover.FilterSlideover.activefilters": "{count, plural, one {# Active Filter} other {# Active Filters}}", + "components.Discover.FilterSlideover.anyRelease": "Any", "components.Discover.FilterSlideover.certification": "Content Rating", "components.Discover.FilterSlideover.clearfilters": "Clear Active Filters", + "components.Discover.FilterSlideover.digital": "Digital", "components.Discover.FilterSlideover.excludeKeywords": "Exclude Keywords", "components.Discover.FilterSlideover.filters": "Filters", "components.Discover.FilterSlideover.firstAirDate": "First Air Date", "components.Discover.FilterSlideover.from": "From", "components.Discover.FilterSlideover.genres": "Genres", "components.Discover.FilterSlideover.keywords": "Keywords", + "components.Discover.FilterSlideover.limitedTheatrical": "Theatrical (Limited)", + "components.Discover.FilterSlideover.minimumRelease": "Minimum Release", "components.Discover.FilterSlideover.originalLanguage": "Original Language", + "components.Discover.FilterSlideover.physical": "Physical", "components.Discover.FilterSlideover.ratingText": "Ratings between {minValue} and {maxValue}", "components.Discover.FilterSlideover.releaseDate": "Release Date", "components.Discover.FilterSlideover.runtime": "Runtime", @@ -98,9 +103,11 @@ "components.Discover.FilterSlideover.status": "Status", "components.Discover.FilterSlideover.streamingservices": "Streaming Services", "components.Discover.FilterSlideover.studio": "Studio", + "components.Discover.FilterSlideover.theatrical": "Theatrical", "components.Discover.FilterSlideover.tmdbuserscore": "TMDB User Score", "components.Discover.FilterSlideover.tmdbuservotecount": "TMDB User Vote Count", "components.Discover.FilterSlideover.to": "To", + "components.Discover.FilterSlideover.tvRelease": "TV", "components.Discover.FilterSlideover.voteCount": "Number of votes between {minValue} and {maxValue}", "components.Discover.MovieGenreList.moviegenres": "Movie Genres", "components.Discover.MovieGenreSlider.moviegenres": "Movie Genres",