@@ -8,8 +8,11 @@ import type {
88} from '@server/api/themoviedb/interfaces' ;
99import type { UserContentRatingLimits } from '@server/constants/contentRatings' ;
1010import {
11+ MOVIE_RATINGS ,
1112 shouldFilterMovie ,
1213 shouldFilterTv ,
14+ UNRATED_VALUES ,
15+ type MovieRating ,
1316} from '@server/constants/contentRatings' ;
1417import { MediaType } from '@server/constants/media' ;
1518import { getRepository } from '@server/datasource' ;
@@ -42,15 +45,15 @@ export const createTmdbWithRegionLanguage = (user?: User): TheMovieDb => {
4245 user ?. settings ?. streamingRegion === 'all'
4346 ? ''
4447 : user ?. settings ?. streamingRegion
45- ? user ?. settings ?. streamingRegion
46- : settings . main . discoverRegion ;
48+ ? user ?. settings ?. streamingRegion
49+ : settings . main . discoverRegion ;
4750
4851 const originalLanguage =
4952 user ?. settings ?. originalLanguage === 'all'
5053 ? ''
5154 : user ?. settings ?. originalLanguage
52- ? user ?. settings ?. originalLanguage
53- : settings . main . originalLanguage ;
55+ ? user ?. settings ?. originalLanguage
56+ : settings . main . originalLanguage ;
5457
5558 return new TheMovieDb ( {
5659 discoverRegion,
@@ -69,6 +72,7 @@ export const getUserContentRatingLimits = (
6972 maxMovieRating : user ?. settings ?. maxMovieRating ?? undefined ,
7073 maxTvRating : user ?. settings ?. maxTvRating ?? undefined ,
7174 blockUnrated : user ?. settings ?. blockUnrated ?? false ,
75+ blockAdult : user ?. settings ?. blockAdult ?? false ,
7276 } ;
7377} ;
7478
@@ -136,6 +140,60 @@ const BACKFILL_THRESHOLD = 15;
136140 * When filtering drops results below BACKFILL_THRESHOLD, fetches one
137141 * additional TMDB page to compensate for the gap.
138142 */
143+ /**
144+ * Extract the best US movie certification from release dates.
145+ * Collects ALL US release date certifications, excludes NR/unrated
146+ * (so unrated director's cuts don't override a theatrical R rating),
147+ * and returns the most restrictive one found.
148+ * Falls back to international ratings if no US rating exists.
149+ */
150+ const getMovieCertFromDetails = (
151+ releaseDates : {
152+ iso_3166_1 : string ;
153+ release_dates : { certification : string } [ ] ;
154+ } [ ]
155+ ) : string | undefined => {
156+ const usRelease = releaseDates . find ( ( r ) => r . iso_3166_1 === 'US' ) ;
157+ const usCerts : string [ ] = [ ] ;
158+
159+ if ( usRelease ?. release_dates ) {
160+ for ( const rd of usRelease . release_dates ) {
161+ if ( rd . certification && ! UNRATED_VALUES . includes ( rd . certification ) ) {
162+ usCerts . push ( rd . certification ) ;
163+ }
164+ }
165+ }
166+
167+ if ( usCerts . length > 0 ) {
168+ // Return the most restrictive US rating
169+ let best = usCerts [ 0 ] ;
170+ let bestIdx = MOVIE_RATINGS . indexOf ( best as MovieRating ) ;
171+ for ( const c of usCerts ) {
172+ const idx = MOVIE_RATINGS . indexOf ( c as MovieRating ) ;
173+ if ( idx > bestIdx ) {
174+ bestIdx = idx ;
175+ best = c ;
176+ }
177+ }
178+ return best ;
179+ }
180+
181+ // Fallback: check all countries for a known MPAA-equivalent rating
182+ for ( const release of releaseDates ) {
183+ for ( const rd of release . release_dates || [ ] ) {
184+ if (
185+ rd . certification &&
186+ ! UNRATED_VALUES . includes ( rd . certification ) &&
187+ MOVIE_RATINGS . indexOf ( rd . certification as MovieRating ) !== - 1
188+ ) {
189+ return rd . certification ;
190+ }
191+ }
192+ }
193+
194+ return undefined ;
195+ } ;
196+
139197const filterMovieBatch = async (
140198 movies : TmdbMovieResult [ ] ,
141199 tmdb : TheMovieDb ,
@@ -144,22 +202,27 @@ const filterMovieBatch = async (
144202 const settled = await Promise . allSettled (
145203 movies . map ( async ( movie ) => {
146204 const details = await tmdb . getMovie ( { movieId : movie . id } ) ;
147- const usRelease = details . release_dates ?. results ?. find (
148- ( r ) => r . iso_3166_1 === 'US'
205+ const cert = getMovieCertFromDetails (
206+ details . release_dates ?. results ?? [ ]
149207 ) ;
150- const cert = usRelease ?. release_dates ?. find (
151- ( rd ) => rd . certification
152- ) ?. certification ;
153- return { movie, cert } ;
208+ return { movie, cert, title : details . title } ;
154209 } )
155210 ) ;
156211
157212 const filtered : TmdbMovieResult [ ] = [ ] ;
158213 for ( const outcome of settled ) {
159214 if ( outcome . status !== 'fulfilled' ) continue ;
160- const { movie, cert } = outcome . value ;
215+ const { movie, cert, title } = outcome . value ;
161216 if ( ! shouldFilterMovie ( cert , limits . maxMovieRating , true ) ) {
162217 filtered . push ( movie ) ;
218+ } else {
219+ logger . debug ( 'Blocked movie by rating (post-filter)' , {
220+ label : 'Content Filtering' ,
221+ movieId : movie . id ,
222+ movieTitle : title ,
223+ certification : cert ?? 'unrated' ,
224+ maxRating : limits . maxMovieRating ,
225+ } ) ;
163226 }
164227 }
165228 return filtered ;
@@ -171,15 +234,23 @@ const postFilterDiscoverMovies = async (
171234 limits : UserContentRatingLimits ,
172235 fetchNextPage ?: ( ) => Promise < TmdbMovieResult [ ] | null >
173236) : Promise < TmdbMovieResult [ ] > => {
174- if ( ! limits . blockUnrated ) return results ;
237+ // Free in-memory filter: remove TMDB adult-flagged content
238+ let filtered = limits . blockAdult
239+ ? results . filter ( ( movie ) => ! movie . adult )
240+ : results ;
175241
176- const filtered = await filterMovieBatch ( results , tmdb , limits ) ;
242+ if ( ! limits . blockUnrated ) return filtered ;
243+
244+ filtered = await filterMovieBatch ( filtered , tmdb , limits ) ;
177245
178246 // Backfill: if too many results were removed, grab one more page
179247 if ( filtered . length < BACKFILL_THRESHOLD && fetchNextPage ) {
180248 const nextResults = await fetchNextPage ( ) ;
181249 if ( nextResults && nextResults . length > 0 ) {
182- const nextFiltered = await filterMovieBatch ( nextResults , tmdb , limits ) ;
250+ const nextInput = limits . blockAdult
251+ ? nextResults . filter ( ( movie ) => ! movie . adult )
252+ : nextResults ;
253+ const nextFiltered = await filterMovieBatch ( nextInput , tmdb , limits ) ;
183254 filtered . push ( ...nextFiltered ) ;
184255 }
185256 }
@@ -198,16 +269,24 @@ const filterTvBatch = async (
198269 const usRating = details . content_ratings ?. results ?. find (
199270 ( r ) => r . iso_3166_1 === 'US'
200271 ) ;
201- return { show, cert : usRating ?. rating } ;
272+ return { show, cert : usRating ?. rating , title : details . name } ;
202273 } )
203274 ) ;
204275
205276 const filtered : TmdbTvResult [ ] = [ ] ;
206277 for ( const outcome of settled ) {
207278 if ( outcome . status !== 'fulfilled' ) continue ;
208- const { show, cert } = outcome . value ;
279+ const { show, cert, title } = outcome . value ;
209280 if ( ! shouldFilterTv ( cert , limits . maxTvRating , true ) ) {
210281 filtered . push ( show ) ;
282+ } else {
283+ logger . debug ( 'Blocked TV show by rating (post-filter)' , {
284+ label : 'Content Filtering' ,
285+ tvId : show . id ,
286+ tvTitle : title ,
287+ certification : cert ?? 'unrated' ,
288+ maxRating : limits . maxTvRating ,
289+ } ) ;
211290 }
212291 }
213292 return filtered ;
@@ -779,8 +858,9 @@ discoverRoutes.get('/tv', async (req, res, next) => {
779858 ratingLimits ,
780859 tvPage < data . total_pages
781860 ? async ( ) =>
782- ( await tmdb . getDiscoverTv ( { page : tvPage + 1 , ...tvDiscoverOpts } ) )
783- . results
861+ (
862+ await tmdb . getDiscoverTv ( { page : tvPage + 1 , ...tvDiscoverOpts } )
863+ ) . results
784864 : undefined
785865 ) ;
786866
@@ -1153,23 +1233,58 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
11531233
11541234discoverRoutes . get ( '/trending' , async ( req , res , next ) => {
11551235 const tmdb = createTmdbWithRegionLanguage ( req . user ) ;
1236+ const ratingLimits = getUserContentRatingLimits ( req . user ) ;
1237+ const hasLimits =
1238+ ratingLimits . maxMovieRating ||
1239+ ratingLimits . maxTvRating ||
1240+ ratingLimits . blockUnrated ||
1241+ ratingLimits . blockAdult ;
11561242
11571243 try {
11581244 const data = await tmdb . getAllTrending ( {
11591245 page : Number ( req . query . page ) ,
11601246 language : ( req . query . language as string ) ?? req . locale ,
11611247 } ) ;
11621248
1249+ // Post-filter trending results if user has any parental controls
1250+ let filteredResults = data . results ;
1251+ if ( hasLimits ) {
1252+ const movieResults = data . results . filter ( isMovie ) as TmdbMovieResult [ ] ;
1253+ const tvResults = data . results . filter (
1254+ ( r ) => ! isMovie ( r ) && ! isPerson ( r ) && ! isCollection ( r )
1255+ ) as TmdbTvResult [ ] ;
1256+ const otherResults = data . results . filter (
1257+ ( r ) => isPerson ( r ) || isCollection ( r )
1258+ ) ;
1259+
1260+ const filteredMovies = await postFilterDiscoverMovies (
1261+ movieResults ,
1262+ tmdb ,
1263+ ratingLimits
1264+ ) ;
1265+ const filteredTv = await postFilterDiscoverTv (
1266+ tvResults ,
1267+ tmdb ,
1268+ ratingLimits
1269+ ) ;
1270+
1271+ filteredResults = [
1272+ ...filteredMovies ,
1273+ ...filteredTv ,
1274+ ...otherResults ,
1275+ ] as typeof data . results ;
1276+ }
1277+
11631278 const media = await Media . getRelatedMedia (
11641279 req . user ,
1165- data . results . map ( ( result ) => result . id )
1280+ filteredResults . map ( ( result ) => result . id )
11661281 ) ;
11671282
11681283 return res . status ( 200 ) . json ( {
11691284 page : data . page ,
11701285 totalPages : data . total_pages ,
11711286 totalResults : data . total_results ,
1172- results : data . results . map ( ( result ) =>
1287+ results : filteredResults . map ( ( result ) =>
11731288 isMovie ( result )
11741289 ? mapMovieResult (
11751290 result ,
@@ -1179,16 +1294,16 @@ discoverRoutes.get('/trending', async (req, res, next) => {
11791294 )
11801295 )
11811296 : isPerson ( result )
1182- ? mapPersonResult ( result )
1183- : isCollection ( result )
1184- ? mapCollectionResult ( result )
1185- : mapTvResult (
1186- result ,
1187- media . find (
1188- ( med ) =>
1189- med . tmdbId === result . id && med . mediaType === MediaType . TV
1190- )
1191- )
1297+ ? mapPersonResult ( result )
1298+ : isCollection ( result )
1299+ ? mapCollectionResult ( result )
1300+ : mapTvResult (
1301+ result ,
1302+ media . find (
1303+ ( med ) =>
1304+ med . tmdbId === result . id && med . mediaType === MediaType . TV
1305+ )
1306+ )
11921307 ) ,
11931308 } ) ;
11941309 } catch ( e ) {
0 commit comments