Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ yarn-error.log*
config/db/*.sqlite3*
config/settings.json
config/settings.old.json
config-test/

# logs
config/logs/*.log*
Expand Down
6 changes: 6 additions & 0 deletions server/entity/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export class UserSettings {
@Column({ nullable: true })
public watchlistSyncTv?: boolean;

@Column({ nullable: true })
public maxMovieRating?: string;

@Column({ nullable: true })
public maxTvRating?: string;

@Column({
type: 'text',
nullable: true,
Expand Down
2 changes: 2 additions & 0 deletions server/interfaces/api/userSettingsInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface UserSettingsGeneralResponse {
globalTvQuotaDays?: number;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;
maxMovieRating?: string;
maxTvRating?: string;
}

export type NotificationAgentTypes = Record<NotificationAgentKey, number>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddContentRatingFilters1736000000000
implements MigrationInterface
{
name = 'AddContentRatingFilters1736000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "maxMovieRating" character varying`
);
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "maxTvRating" character varying`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "maxTvRating"`
);
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "maxMovieRating"`
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddContentRatingFilters1736000000000
implements MigrationInterface
{
name = 'AddContentRatingFilters1736000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
// Add maxMovieRating and maxTvRating columns to user_settings
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, "telegramMessageThreadId" varchar, "maxMovieRating" varchar, "maxTvRating" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Remove maxMovieRating and maxTvRating columns
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "pushoverSound" varchar, "discoverRegion" varchar, "streamingRegion" varchar, "telegramMessageThreadId" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId") SELECT "id", "notificationTypes", "discordId", "userId", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "watchlistSyncMovies", "watchlistSyncTv", "pushoverSound", "discoverRegion", "streamingRegion", "telegramMessageThreadId" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}
13 changes: 12 additions & 1 deletion server/routes/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import TheMovieDb from '@server/api/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';
import { filterMoviesByRating } from '@server/utils/contentFiltering';
import { Router } from 'express';

const collectionRoutes = Router();
Expand All @@ -20,7 +21,17 @@ collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
collection.parts.map((part) => part.id)
);

return res.status(200).json(mapCollection(collection, media));
// Filter collection parts based on content rating
const filteredParts = await filterMoviesByRating(
collection.parts,
req.user
);
const filteredCollection = {
...collection,
parts: filteredParts,
};

return res.status(200).json(mapCollection(filteredCollection, media));
} catch (e) {
logger.debug('Something went wrong retrieving collection', {
label: 'API',
Expand Down
65 changes: 51 additions & 14 deletions server/routes/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import {
mapTvResult,
} from '@server/models/Search';
import { mapNetwork } from '@server/models/Tv';
import {
filterMoviesByRating,
filterTvByRating,
} from '@server/utils/contentFiltering';
import { isCollection, isMovie, isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import { sortBy } from 'lodash';
Expand Down Expand Up @@ -127,6 +131,9 @@ discoverRoutes.get('/movies', async (req, res, next) => {
data.results.map((result) => result.id)
);

// Apply content rating filters
const filteredResults = await filterMoviesByRating(data.results, req.user);

let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
Expand All @@ -145,9 +152,9 @@ discoverRoutes.get('/movies', async (req, res, next) => {
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
totalResults: filteredResults.length,
keywords: keywordData,
results: data.results.map((result) =>
results: filteredResults.map((result) =>
mapMovieResult(
result,
media.find(
Expand Down Expand Up @@ -297,17 +304,22 @@ discoverRoutes.get<{ studioId: string }>(
studio: req.params.studioId as string,
});

const filteredResults = await filterMoviesByRating(
data.results,
req.user
);

const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
filteredResults.map((result) => result.id)
);

return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
studio: mapProductionCompany(studio),
results: data.results.map((result) =>
results: filteredResults.map((result) =>
mapMovieResult(
result,
media.find(
Expand Down Expand Up @@ -352,11 +364,13 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => {
data.results.map((result) => result.id)
);

const filteredResults = await filterMoviesByRating(data.results, req.user);

return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
totalResults: filteredResults.length,
results: filteredResults.map((result) =>
mapMovieResult(
result,
media.find(
Expand Down Expand Up @@ -420,6 +434,9 @@ discoverRoutes.get('/tv', async (req, res, next) => {
data.results.map((result) => result.id)
);

// Apply content rating filters
const filteredResults = await filterTvByRating(data.results, req.user);

let keywordData: TmdbKeyword[] = [];
if (keywords) {
const splitKeywords = keywords.split(',');
Expand All @@ -438,9 +455,9 @@ discoverRoutes.get('/tv', async (req, res, next) => {
return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
totalResults: filteredResults.length,
keywords: keywordData,
results: data.results.map((result) =>
results: filteredResults.map((result) =>
mapTvResult(
result,
media.find(
Expand Down Expand Up @@ -589,17 +606,19 @@ discoverRoutes.get<{ networkId: string }>(
network: Number(req.params.networkId),
});

const filteredResults = await filterTvByRating(data.results, req.user);

const media = await Media.getRelatedMedia(
req.user,
data.results.map((result) => result.id)
filteredResults.map((result) => result.id)
);

return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
network: mapNetwork(network),
results: data.results.map((result) =>
results: filteredResults.map((result) =>
mapTvResult(
result,
media.find(
Expand Down Expand Up @@ -644,11 +663,13 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => {
data.results.map((result) => result.id)
);

const filteredResults = await filterTvByRating(data.results, req.user);

return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
totalResults: filteredResults.length,
results: filteredResults.map((result) =>
mapTvResult(
result,
media.find(
Expand Down Expand Up @@ -683,11 +704,27 @@ discoverRoutes.get('/trending', async (req, res, next) => {
data.results.map((result) => result.id)
);

// Filter results based on media type
const filteredResults = [];
for (const result of data.results) {
if (isMovie(result)) {
const filtered = await filterMoviesByRating([result], req.user);
if (filtered.length > 0) filteredResults.push(result);
} else if (!isPerson(result) && !isCollection(result)) {
// It's a TV show
const filtered = await filterTvByRating([result], req.user);
if (filtered.length > 0) filteredResults.push(result);
} else {
// Keep persons and collections
filteredResults.push(result);
}
}

return res.status(200).json({
page: data.page,
totalPages: data.total_pages,
totalResults: data.total_results,
results: data.results.map((result) =>
totalResults: filteredResults.length,
results: filteredResults.map((result) =>
isMovie(result)
? mapMovieResult(
result,
Expand Down
53 changes: 49 additions & 4 deletions server/routes/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli';
import TheMovieDb from '@server/api/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
Expand All @@ -15,6 +19,10 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import {
filterMoviesByRating,
filterTvByRating,
} from '@server/utils/contentFiltering';
import { Router } from 'express';
import type { FindOneOptions } from 'typeorm';
import { In } from 'typeorm';
Expand Down Expand Up @@ -69,22 +77,59 @@ mediaRoutes.get('/', async (req, res, next) => {
}

try {
const [media, mediaCount] = await mediaRepository.findAndCount({
const [media] = await mediaRepository.findAndCount({
order: sortFilter,
where: statusFilter && {
status: statusFilter,
},
take: pageSize,
skip,
});

// Filter media based on content ratings
let filteredMedia = media;
if (req.user?.settings?.maxMovieRating || req.user?.settings?.maxTvRating) {
const filtered: Media[] = [];

for (const item of media) {
if (item.mediaType === MediaType.MOVIE) {
const mockResult = {
id: item.tmdbId,
adult: false,
media_type: item.mediaType,
} as Partial<TmdbMovieResult> & { id: number };
const allowed = await filterMoviesByRating(
[mockResult as TmdbMovieResult],
req.user
);
if (allowed.length > 0) filtered.push(item);
} else if (item.mediaType === MediaType.TV) {
const mockResult = {
id: item.tmdbId,
adult: false,
media_type: item.mediaType,
} as Partial<TmdbTvResult> & { id: number };
const allowed = await filterTvByRating(
[mockResult as TmdbTvResult],
req.user
);
if (allowed.length > 0) filtered.push(item);
} else {
filtered.push(item);
}
}

filteredMedia = filtered;
}

return res.status(200).json({
pageInfo: {
pages: Math.ceil(mediaCount / pageSize),
pages: Math.ceil(filteredMedia.length / pageSize),
pageSize,
results: mediaCount,
results: filteredMedia.length,
page: Math.ceil(skip / pageSize) + 1,
},
results: media,
results: filteredMedia,
} as MediaResultsResponse);
} catch (e) {
next({ status: 500, message: e.message });
Expand Down
Loading