Skip to content

Commit 3b64d9e

Browse files
committed
feat: update to inlcude trending filter and improve rating fallback
1 parent f8ee51d commit 3b64d9e

10 files changed

Lines changed: 364 additions & 36 deletions

File tree

server/constants/contentRatings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface UserContentRatingLimits {
2828
maxMovieRating?: string;
2929
maxTvRating?: string;
3030
blockUnrated?: boolean;
31+
blockAdult?: boolean;
3132
}
3233

3334
/**

server/entity/UserSettings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ export class UserSettings {
8383
@Column({ default: false })
8484
public blockUnrated?: boolean; // Block content with no rating (NR, unrated)
8585

86+
@Column({ default: false })
87+
public blockAdult?: boolean; // Block adult content (TMDB adult flag)
88+
8689
@Column({
8790
type: 'text',
8891
nullable: true,

server/interfaces/api/userSettingsInterfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface UserSettingsParentalControlsResponse {
2424
maxMovieRating?: string;
2525
maxTvRating?: string;
2626
blockUnrated?: boolean;
27+
blockAdult?: boolean;
2728
}
2829

2930
export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddBlockAdult1770627987305 implements MigrationInterface {
4+
name = 'AddBlockAdult1770627987305';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user_settings" ADD "blockAdult" boolean DEFAULT false`
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(
14+
`ALTER TABLE "user_settings" DROP COLUMN "blockAdult"`
15+
);
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddBlockAdult1770627987305 implements MigrationInterface {
4+
name = 'AddBlockAdult1770627987305';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user_settings" ADD "blockAdult" boolean DEFAULT (0)`
9+
);
10+
}
11+
12+
public async down(queryRunner: QueryRunner): Promise<void> {
13+
await queryRunner.query(
14+
`ALTER TABLE "user_settings" DROP COLUMN "blockAdult"`
15+
);
16+
}
17+
}

server/routes/discover.ts

Lines changed: 145 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import type {
88
} from '@server/api/themoviedb/interfaces';
99
import type { UserContentRatingLimits } from '@server/constants/contentRatings';
1010
import {
11+
MOVIE_RATINGS,
1112
shouldFilterMovie,
1213
shouldFilterTv,
14+
UNRATED_VALUES,
15+
type MovieRating,
1316
} from '@server/constants/contentRatings';
1417
import { MediaType } from '@server/constants/media';
1518
import { 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+
139197
const 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

11541234
discoverRoutes.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

Comments
 (0)