refactor: rename blacklist to blocklist (#2157)

Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
Co-authored-by: 0xsysr3ll <0xsysr3ll@pm.me>
Co-authored-by: gauthier-th <mail@gauthierth.fr>
This commit is contained in:
Conlan Kreher
2026-02-14 08:31:45 -05:00
committed by GitHub
parent 15be3d7475
commit 33a5d9a9ac
55 changed files with 842 additions and 535 deletions

View File

@@ -17,6 +17,6 @@ export enum MediaStatus {
PROCESSING,
PARTIALLY_AVAILABLE,
AVAILABLE,
BLACKLISTED,
BLOCKLISTED,
DELETED,
}

View File

@@ -2,7 +2,7 @@ import { MediaStatus, type MediaType } from '@server/constants/media';
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 { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import type { EntityManager } from 'typeorm';
import {
@@ -19,7 +19,7 @@ import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
export class Blacklist implements BlacklistItem {
export class Blocklist implements BlocklistItem {
@PrimaryGeneratedColumn()
public id: number;
@@ -38,65 +38,65 @@ export class Blacklist implements BlacklistItem {
})
user?: User;
@OneToOne(() => Media, (media) => media.blacklist, {
@OneToOne(() => Media, (media) => media.blocklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;
@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;
public blocklistedTags?: string;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
constructor(init?: Partial<Blacklist>) {
constructor(init?: Partial<Blocklist>) {
Object.assign(this, init);
}
public static async addToBlacklist(
public static async addToBlocklist(
{
blacklistRequest,
blocklistRequest,
}: {
blacklistRequest: {
blocklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
blacklistedTags?: string;
blocklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
const blacklist = new this({
...blacklistRequest,
const blocklist = new this({
...blocklistRequest,
});
const mediaRepository = em.getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
tmdbId: blocklistRequest.tmdbId,
},
});
const blacklistRepository = em.getRepository(this);
const blocklistRepository = em.getRepository(this);
await blacklistRepository.save(blacklist);
await blocklistRepository.save(blocklist);
if (!media) {
media = new Media({
tmdbId: blacklistRequest.tmdbId,
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist),
tmdbId: blocklistRequest.tmdbId,
status: MediaStatus.BLOCKLISTED,
status4k: MediaStatus.BLOCKLISTED,
mediaType: blocklistRequest.mediaType,
blocklist: Promise.resolve(blocklist),
});
await mediaRepository.save(media);
} else {
media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;
media.blocklist = Promise.resolve(blocklist);
media.status = MediaStatus.BLOCKLISTED;
media.status4k = MediaStatus.BLOCKLISTED;
await mediaRepository.save(media);
}

View File

@@ -3,7 +3,7 @@ import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker';
@@ -126,8 +126,8 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@OneToOne(() => Blocklist, (blocklist) => blocklist.media)
public blocklist: Promise<Blocklist>;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;

View File

@@ -35,7 +35,7 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {}
export class BlocklistedMediaError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
@@ -140,14 +140,14 @@ export class MediaRequest {
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.BLACKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', {
if (media.status === MediaStatus.BLOCKLISTED) {
logger.warn('Request for media blocked due to being blocklisted', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
label: 'Media Request',
});
throw new BlacklistedMediaError('This media is blacklisted.');
throw new BlocklistedMediaError('This media is blocklisted.');
}
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {

View File

@@ -1,15 +1,15 @@
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem {
export interface BlocklistItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user?: User;
blacklistedTags?: string;
blocklistedTags?: string;
}
export interface BlacklistResultsResponse extends PaginatedResponse {
results: BlacklistItem[];
export interface BlocklistResultsResponse extends PaginatedResponse {
results: BlocklistItem[];
}

View File

@@ -30,7 +30,7 @@ export interface PublicSettingsResponse {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;

View File

@@ -6,7 +6,7 @@ import type {
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
@@ -20,7 +20,7 @@ import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;
@@ -30,12 +30,12 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
await this.cleanBlocklist(em);
await this.createBlocklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
logger.info('Aborting job: Process Blocklisted Tags', {
label: 'Jobs',
});
} else {
@@ -64,37 +64,37 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
this.cancel();
}
private async createBlacklistEntries(em: EntityManager) {
private async createBlocklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');
const blocklistedTags = settings.main.blocklistedTags;
const blocklistedTagsArr = blocklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
const pageLimit = settings.main.blocklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) {
if (blocklistedTags.length === 0) {
return;
}
// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
2 * blocklistedTagsArr.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) {
for (const tag of blocklistedTagsArr) {
const keywordDetails = await tmdb.getKeywordDetails({
keywordId: Number(tag),
});
if (keywordDetails === null) {
logger.warn('Skipping invalid keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
logger.warn('Skipping invalid keyword in blocklisted tags', {
label: 'Blocklisted Tags Processor',
keywordId: tag,
});
invalidKeywords.add(tag);
@@ -134,8 +134,8 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
queryMax = response.total_pages;
}
} catch (error) {
logger.error('Error processing keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
logger.error('Error processing keyword in blocklisted tags', {
label: 'Blocklisted Tags Processor',
keywordId: tag,
errorMessage: error.message,
});
@@ -145,19 +145,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
}
if (invalidKeywords.size > 0) {
const currentTags = blacklistedTagsArr.filter(
const currentTags = blocklistedTagsArr.filter(
(tag) => !invalidKeywords.has(tag)
);
const cleanedTags = currentTags.join(',');
if (cleanedTags !== blacklistedTags) {
settings.main.blacklistedTags = cleanedTags;
if (cleanedTags !== blocklistedTags) {
settings.main.blocklistedTags = cleanedTags;
await settings.save();
logger.info('Cleaned up invalid keywords from settings', {
label: 'Blacklisted Tags Processor',
label: 'Blocklisted Tags Processor',
removedKeywords: Array.from(invalidKeywords),
newBlacklistedTags: cleanedTags,
newBlocklistedTags: cleanedTags,
});
}
}
@@ -169,33 +169,33 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);
const blocklistRepository = em.getRepository(Blocklist);
for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
const blocklistEntry = await blocklistRepository.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 (blocklistEntry) {
// Don't mark manual blocklists with tags
// If media wasn't previously blocklisted for this tag, add the tag to the media's blocklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
blocklistEntry.blocklistedTags &&
!blocklistEntry.blocklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
await blocklistRepository.update(blocklistEntry.id, {
blocklistedTags: `${blocklistEntry.blocklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
// Media wasn't previously blocklisted, add it to the blocklist
await Blocklist.addToBlocklist(
{
blacklistRequest: {
blocklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
blocklistedTags: `,${keywordId},`,
},
},
em
@@ -204,22 +204,22 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
}
}
private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
private async cleanBlocklist(em: EntityManager) {
// Remove blocklist and media entries blocklisted 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`)
.innerJoinAndSelect(Blocklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blocklistedTags 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
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blocklist entries via cascading
}
}
}
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
const blocklistedTagsProcessor = new BlocklistedTagProcessor();
export default blacklistedTagsProcessor;
export default blocklistedTagsProcessor;

View File

@@ -1,5 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
@@ -239,19 +239,19 @@ export const startJobs = (): void => {
});
scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
id: 'process-blocklisted-tags',
name: 'Process Blocklisted 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', {
cronSchedule: jobs['process-blocklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blocklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blocklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
blocklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
running: () => blocklistedTagsProcessor.status().running,
cancelFn: () => blocklistedTagsProcessor.cancel(),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

View File

@@ -27,8 +27,8 @@ export enum Permission {
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLACKLIST = 268435456,
VIEW_BLACKLIST = 1073741824,
MANAGE_BLOCKLIST = 268435456,
VIEW_BLOCKLIST = 1073741824,
}
export interface PermissionCheckOptions {

View File

@@ -132,15 +132,15 @@ export interface MainSettings {
tv: Quota;
};
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blacklistedTags: string;
blacklistedTagsLimit: number;
blocklistedTags: string;
blocklistedTagsLimit: number;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
@@ -181,7 +181,7 @@ interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
@@ -346,7 +346,7 @@ export type JobId =
| 'jellyfin-full-scan'
| 'image-cache-cleanup'
| 'availability-sync'
| 'process-blacklisted-tags';
| 'process-blocklisted-tags';
export interface AllSettings {
clientId: string;
@@ -389,15 +389,15 @@ class Settings {
tv: {},
},
hideAvailable: false,
hideBlacklisted: false,
hideBlocklisted: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blacklistedTags: '',
blacklistedTagsLimit: 50,
blocklistedTags: '',
blocklistedTagsLimit: 50,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
@@ -570,7 +570,7 @@ class Settings {
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
'process-blacklisted-tags': {
'process-blocklisted-tags': {
schedule: '0 30 1 */7 * *',
},
},
@@ -671,7 +671,7 @@ class Settings {
applicationTitle: this.data.main.applicationTitle,
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
hideBlacklisted: this.data.main.hideBlacklisted,
hideBlocklisted: this.data.main.hideBlocklisted,
localLogin: this.data.main.localLogin,
mediaServerLogin: this.data.main.mediaServerLogin,
jellyfinExternalHost: this.data.jellyfin.externalHostname,

View File

@@ -0,0 +1,40 @@
import type { AllSettings } from '@server/lib/settings';
const migrateBlacklistToBlocklist = (settings: any): AllSettings => {
if (
Array.isArray(settings.migrations) &&
settings.migrations.includes('0008_migrate_blacklist_to_blocklist')
) {
return settings;
}
if (settings.main?.hideBlacklisted !== undefined) {
settings.main.hideBlocklisted = settings.main.hideBlacklisted;
delete settings.main.hideBlacklisted;
}
if (settings.main?.blacklistedTags !== undefined) {
settings.main.blocklistedTags = settings.main.blacklistedTags;
delete settings.main.blacklistedTags;
}
if (settings.main?.blacklistedTagsLimit !== undefined) {
settings.main.blocklistedTagsLimit = settings.main.blacklistedTagsLimit;
delete settings.main.blacklistedTagsLimit;
}
if (settings.jobs?.['process-blacklisted-tags']) {
settings.jobs['process-blocklisted-tags'] =
settings.jobs['process-blacklisted-tags'];
delete settings.jobs['process-blacklisted-tags'];
}
if (!Array.isArray(settings.migrations)) {
settings.migrations = [];
}
settings.migrations.push('0008_migrate_blacklist_to_blocklist');
return settings;
};
export default migrateBlacklistToBlocklist;

View File

@@ -3,7 +3,7 @@ import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
BlocklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -145,8 +145,8 @@ class WatchlistSync {
errorMessage: e.message,
});
break;
// Blacklisted media should be silently ignored during watchlist sync to avoid spam
case BlacklistedMediaError:
// Blocklisted media should be silently ignored during watchlist sync to avoid spam
case BlocklistedMediaError:
break;
default:
logger.error('Failed to create media request from watchlist', {

View File

@@ -0,0 +1,49 @@
import logger from '@server/logger';
import type { NextFunction, Request, Response } from 'express';
interface DeprecationOptions {
oldPath: string;
newPath: string;
sunsetDate?: string;
documentationUrl?: string;
}
/**
* Mark an API route as deprecated.
* @see https://datatracker.ietf.org/doc/html/rfc8594
*/
export const deprecatedRoute = ({
oldPath,
newPath,
sunsetDate,
documentationUrl,
}: DeprecationOptions) => {
return (req: Request, res: Response, next: NextFunction) => {
logger.warn(
`Deprecated API endpoint accessed: ${oldPath} → use ${newPath} instead`,
{
label: 'API Deprecation',
ip: req.ip,
userAgent: req.get('User-Agent'),
method: req.method,
path: req.originalUrl,
}
);
res.setHeader('Deprecation', 'true');
const links: string[] = [`<${newPath}>; rel="successor-version"`];
if (documentationUrl) {
links.push(`<${documentationUrl}>; rel="deprecation"`);
}
res.setHeader('Link', links.join(', '));
if (sunsetDate) {
res.setHeader('Sunset', new Date(sunsetDate).toUTCString());
}
next();
};
};
export default deprecatedRoute;

View File

@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameBlacklistToBlocklist1746900000000 implements MigrationInterface {
name = 'RenameBlacklistToBlocklist1746900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blacklist" RENAME TO "blocklist"`);
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME COLUMN "blacklistedTags" TO "blocklistedTags"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME COLUMN "blocklistedTags" TO "blacklistedTags"`
);
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
}
}

View File

@@ -0,0 +1,66 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameBlacklistToBlocklist1746900000000 implements MigrationInterface {
name = 'RenameBlacklistToBlocklist1746900000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "temporary_blocklist" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"mediaType" varchar NOT NULL,
"title" varchar,
"tmdbId" integer NOT NULL,
"blocklistedTags" varchar,
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
"userId" integer,
"mediaId" integer,
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
INSERT INTO "temporary_blocklist" ("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId")
SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
`);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
await queryRunner.query(`
CREATE TABLE "temporary_blacklist" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"mediaType" varchar NOT NULL,
"title" varchar,
"tmdbId" integer NOT NULL,
"blacklistedTags" varchar,
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
"userId" integer,
"mediaId" integer,
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
INSERT INTO "temporary_blacklist" ("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId")
SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
`);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")`
);
}
}

View File

@@ -1,8 +1,8 @@
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import Media from '@server/entity/Media';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -10,53 +10,53 @@ import { Router } from 'express';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
const blocklistRoutes = Router();
export const blacklistAdd = z.object({
export const blocklistAdd = z.object({
tmdbId: z.coerce.number(),
mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(),
user: z.coerce.number(),
});
const blacklistGet = z.object({
const blocklistGet = z.object({
take: z.coerce.number().int().positive().default(25),
skip: z.coerce.number().int().nonnegative().default(0),
search: z.string().optional(),
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
filter: z.enum(['all', 'manual', 'blocklistedTags']).optional(),
});
blacklistRoutes.get(
blocklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
const { take, skip, search, filter } = blacklistGet.parse(req.query);
const { take, skip, search, filter } = blocklistGet.parse(req.query);
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user')
let query = getRepository(Blocklist)
.createQueryBuilder('blocklist')
.leftJoinAndSelect('blocklist.user', 'user')
.where('1 = 1'); // Allow use of andWhere later
switch (filter) {
case 'manual':
query = query.andWhere('blacklist.blacklistedTags IS NULL');
query = query.andWhere('blocklist.blocklistedTags IS NULL');
break;
case 'blacklistedTags':
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
case 'blocklistedTags':
query = query.andWhere('blocklist.blocklistedTags IS NOT NULL');
break;
}
if (search) {
query = query.andWhere('blacklist.title like :title', {
query = query.andWhere('blocklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
const [blocklistedItems, itemsCount] = await query
.orderBy('blocklist.createdAt', 'DESC')
.take(take)
.skip(skip)
.getManyAndCount();
@@ -68,35 +68,35 @@ blacklistRoutes.get(
results: itemsCount,
page: Math.ceil(skip / take) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);
results: blocklistedItems,
} as BlocklistResultsResponse);
} catch (error) {
logger.error('Something went wrong while retrieving blacklisted items', {
label: 'Blacklist',
logger.error('Something went wrong while retrieving blocklisted items', {
label: 'Blocklist',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Unable to retrieve blacklisted items.',
message: 'Unable to retrieve blocklisted items.',
});
}
}
);
blacklistRoutes.get(
blocklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blocklisteRepository = getRepository(Blocklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
const blocklistItem = await blocklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
return res.status(200).send(blocklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
@@ -109,17 +109,17 @@ blacklistRoutes.get(
}
);
blacklistRoutes.post(
blocklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const values = blacklistAdd.parse(req.body);
const values = blocklistAdd.parse(req.body);
await Blacklist.addToBlacklist({
blacklistRequest: values,
await Blocklist.addToBlocklist({
blocklistRequest: values,
});
return res.status(201).send();
@@ -131,12 +131,12 @@ blacklistRoutes.post(
if (error instanceof QueryFailedError) {
switch (error.driverError.errno) {
case 19:
return next({ status: 412, message: 'Item already blacklisted' });
return next({ status: 412, message: 'Item already blocklisted' });
default:
logger.warn('Something wrong with data blacklist', {
logger.warn('Something wrong with data blocklist', {
tmdbId: req.body.tmdbId,
mediaType: req.body.mediaType,
label: 'Blacklist',
label: 'Blocklist',
});
return next({ status: 409, message: 'Something wrong' });
}
@@ -147,20 +147,20 @@ blacklistRoutes.post(
}
);
blacklistRoutes.delete(
blocklistRoutes.delete(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blocklisteRepository = getRepository(Blocklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
const blocklistItem = await blocklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await blacklisteRepository.remove(blacklistItem);
await blocklisteRepository.remove(blocklistItem);
const mediaRepository = getRepository(Media);
@@ -183,4 +183,4 @@ blacklistRoutes.delete(
}
);
export default blacklistRoutes;
export default blocklistRoutes;

View File

@@ -12,6 +12,7 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import deprecatedRoute from '@server/middleware/deprecation';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import { mapWatchProviderDetails } from '@server/models/common';
@@ -28,7 +29,7 @@ import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
import blacklistRoutes from './blacklist';
import blocklistRoutes from './blocklist';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
@@ -151,7 +152,17 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/blocklist', isAuthenticated(), blocklistRoutes);
router.use(
'/blacklist',
isAuthenticated(),
deprecatedRoute({
oldPath: '/api/v1/blacklist',
newPath: '/api/v1/blocklist',
sunsetDate: '2026-06-01',
}),
blocklistRoutes
);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);

View File

@@ -8,7 +8,7 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
BlocklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -326,7 +326,7 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
case BlacklistedMediaError:
case BlocklistedMediaError:
return next({ status: 403, message: error.message });
default:
return next({ status: 500, message: error.message });