Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(blacklist): Automatically add media with blacklisted tags to the blacklist #1306

Open
wants to merge 20 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7d1c800
feat(blacklist): add blacktag settings to main settings page
benbeauchamp7 Jan 25, 2025
8811921
feat(blacklist): create blacktag logic and infrastructure
benbeauchamp7 Jan 25, 2025
9f8447d
feat(blacklist): add scheduling for blacktags job
benbeauchamp7 Jan 25, 2025
c010c27
feat(blacklist): create blacktag ui badge for blacklist
benbeauchamp7 Jan 25, 2025
3e20ccb
docs(blacklist): document blacktags in using-jellyseerr
benbeauchamp7 Jan 25, 2025
397e4e5
fix(blacklist): batch blacklist and media db removes to avoid express…
benbeauchamp7 Jan 25, 2025
cc7fb16
feat(blacklist): allow easy import and export of blacktag configuration
benbeauchamp7 Jan 26, 2025
5f127a1
fix(settings): don't copy the API key every time you press enter on t…
benbeauchamp7 Jan 26, 2025
2b15a03
fix(blacklist): move filter inline with page title to match all the o…
benbeauchamp7 Jan 26, 2025
0393e98
feat(blacklist): allow filtering between manually blacklisted and aut…
benbeauchamp7 Jan 26, 2025
53f1c98
docs(blacklist): reword blacktag documentation a little
benbeauchamp7 Jan 27, 2025
ec9ff7f
refactor(blacklist): remove blacktag settings from public settings in…
benbeauchamp7 Jan 27, 2025
8fb895d
refactor(blacklist): remove unused variable from processResults in bl…
benbeauchamp7 Jan 27, 2025
118289d
refactor(blacklist): change all instances of blacktag to blacklistedT…
benbeauchamp7 Feb 1, 2025
1f22a1b
docs(blacklist): update general documentation for blacklisted tag set…
benbeauchamp7 Feb 4, 2025
c8a9522
fix(blacklist): update setting use of "blacklisted tag" to match betw…
benbeauchamp7 Feb 4, 2025
29508a9
perf(blacklist): remove media type constraint from existing blacklist…
benbeauchamp7 Feb 4, 2025
a1a5e38
fix(blacklist): remove whitespace line causing prettier to fail in CI
benbeauchamp7 Feb 5, 2025
e97e136
refactor(blacklist): swap out some != and == for !s and _s
benbeauchamp7 Feb 7, 2025
edc1802
fix(blacklist): merge back CopyButton changes, disable button when th…
benbeauchamp7 Feb 7, 2025
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
2 changes: 2 additions & 0 deletions cypress/config/settings.cypress.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"discoverRegion": "",
"streamingRegion": "",
"originalLanguage": "",
"blacklistedTags": "",
"blacklistedTagsLimit": 50,
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,
Expand Down
8 changes: 8 additions & 0 deletions docs/using-jellyseerr/settings/general.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting

These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.

## Blacklist Content with Tags and Limit Content Blacklisted per Tag

These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.

The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.

Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.

## Hide Available Media

When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages.
Expand Down
6 changes: 6 additions & 0 deletions overseerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4122,6 +4122,12 @@ paths:
type: string
nullable: true
example: dune
- in: query
name: filter
schema:
type: string
enum: [all, manual, blacklistedTags]
default: manual
responses:
'200':
description: Blacklisted items returned
Expand Down
37 changes: 20 additions & 17 deletions server/api/themoviedb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,26 @@ interface SingleSearchOptions extends SearchOptions {
year?: number;
}

export type SortOptions =
| 'popularity.asc'
| 'popularity.desc'
| 'release_date.asc'
| 'release_date.desc'
| 'revenue.asc'
| 'revenue.desc'
| 'primary_release_date.asc'
| 'primary_release_date.desc'
| 'original_title.asc'
| 'original_title.desc'
| 'vote_average.asc'
| 'vote_average.desc'
| 'vote_count.asc'
| 'vote_count.desc'
| 'first_air_date.asc'
| 'first_air_date.desc';
export const SortOptionsIterable = [
'popularity.desc',
'popularity.asc',
'release_date.desc',
'release_date.asc',
'revenue.desc',
'revenue.asc',
'primary_release_date.desc',
'primary_release_date.asc',
'original_title.asc',
'original_title.desc',
'vote_average.desc',
'vote_average.asc',
'vote_count.desc',
'vote_count.asc',
'first_air_date.desc',
'first_air_date.asc',
] as const;

export type SortOptions = (typeof SortOptionsIterable)[number];

interface DiscoverMovieOptions {
page?: number;
Expand Down
35 changes: 22 additions & 13 deletions server/entity/Blacklist.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { MediaStatus, type MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import dataSource from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { EntityManager } from 'typeorm';
import {
Column,
CreateDateColumn,
Expand Down Expand Up @@ -35,42 +36,50 @@ export class Blacklist implements BlacklistItem {
@ManyToOne(() => User, (user) => user.id, {
eager: true,
})
user: User;
user?: User;

@OneToOne(() => Media, (media) => media.blacklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;

@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;

@CreateDateColumn()
public createdAt: Date;

constructor(init?: Partial<Blacklist>) {
Object.assign(this, init);
}

public static async addToBlacklist({
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
};
}): Promise<void> {
public static async addToBlacklist(
{
blacklistRequest,
}: {
blacklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
blacklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
const blacklist = new this({
...blacklistRequest,
});

const mediaRepository = getRepository(Media);
const mediaRepository = em.getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
},
});

const blacklistRepository = getRepository(this);
const blacklistRepository = em.getRepository(this);

await blacklistRepository.save(blacklist);

Expand Down
3 changes: 2 additions & 1 deletion server/interfaces/api/blacklistInterfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ export interface BlacklistItem {
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user: User;
user?: User;
blacklistedTags?: string;
}

export interface BlacklistResultsResponse extends PaginatedResponse {
Expand Down
184 changes: 184 additions & 0 deletions server/job/blacklistedTagsProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import type { SortOptions } from '@server/api/themoviedb';
import { SortOptionsIterable } from '@server/api/themoviedb';
import type {
TmdbSearchMovieResponse,
TmdbSearchTvResponse,
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { createTmdbWithRegionLanguage } from '@server/routes/discover';
import type { EntityManager } from 'typeorm';

const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}

class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;

public async run() {
this.running = true;

try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
label: 'Jobs',
});
} else {
throw err;
}
} finally {
this.reset();
}
}

public status(): StatusBase {
return {
running: this.running,
progress: this.progress,
total: this.total,
};
}

public cancel() {
this.running = false;
this.progress = 0;
this.total = 0;
}

private reset() {
this.cancel();
}

private async createBlacklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();

const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');

const pageLimit = settings.main.blacklistedTagsLimit;

if (blacklistedTags.length === 0) {
return;
}

// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;

for (const type of [MediaType.MOVIE, MediaType.TV]) {
const getDiscover =
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;

// Iterate for each tag
for (const tag of blacklistedTagsArr) {
let queryMax = pageLimit * SortOptionsIterable.length;
let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag

for (let query = 0; query < queryMax; query++) {
const page: number = fixedSortMode
? query + 1
: (query % pageLimit) + 1;
const sortBy: SortOptions | undefined = fixedSortMode
? undefined
: SortOptionsIterable[query % SortOptionsIterable.length];

if (!this.running) {
throw new AbortTransaction();
}

const response = await getDiscover({
page,
sortBy,
keywords: tag,
});
await this.processResults(response, tag, type, em);
await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS));

this.progress++;
if (page === 1 && response.total_pages <= queryMax) {
// We will finish the tag with less queries than expected, move progress accordingly
this.progress += queryMax - response.total_pages;
fixedSortMode = true;
queryMax = response.total_pages;
}
}
}
}
}

private async processResults(
response: TmdbSearchMovieResponse | TmdbSearchTvResponse,
keywordId: string,
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);

for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
where: { tmdbId: entry.id },
});

if (blacklistEntry) {
// Don't mark manual blacklists with tags
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
{
blacklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
},
},
em
);
}
}
}

private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blacklistedTags IS NOT NULL`)
.getMany();

// Batch removes so the query doesn't get too large
for (let i = 0; i < mediaToRemove.length; i += 500) {
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
}
}
}

const blacklistedTagsProcessor = new BlacklistedTagProcessor();

export default blacklistedTagsProcessor;
19 changes: 18 additions & 1 deletion server/job/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
Expand All @@ -21,7 +22,7 @@ interface ScheduledJob {
job: schedule.Job;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
cronSchedule: string;
running?: () => boolean;
cancelFn?: () => void;
Expand Down Expand Up @@ -237,5 +238,21 @@ export const startJobs = (): void => {
}),
});

scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
type: 'process',
interval: 'days',
cronSchedule: jobs['process-blacklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blacklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
});

logger.info('Scheduled jobs loaded', { label: 'Jobs' });
};
Loading
Loading