feat: blacklist items from Discover page (#632)
* feat: blacklist media items re #490 * feat: blacklist media items * feat: blacklist media items * style: formatting * refactor: close the manage slide-over when the media item is removed from the blacklist * fix: fix media data in the db when blacklisting an item * refactor: refactor component to accept show boolean * refactor: hide watchlist button in the media page when it's blacklisted. Also add a blacklist button * style: formatting --------- Co-authored-by: JoaquinOlivero <joaquin.olivero@hotmail.com>
This commit is contained in:
@@ -16,4 +16,5 @@ export enum MediaStatus {
|
||||
PROCESSING,
|
||||
PARTIALLY_AVAILABLE,
|
||||
AVAILABLE,
|
||||
BLACKLISTED,
|
||||
}
|
||||
|
||||
95
server/entity/Blacklist.ts
Normal file
95
server/entity/Blacklist.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { MediaStatus, type MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { User } from '@server/entity/User';
|
||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import type { ZodNumber, ZodOptional, ZodString } from 'zod';
|
||||
|
||||
@Entity()
|
||||
@Unique(['tmdbId'])
|
||||
export class Blacklist implements BlacklistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
public mediaType: MediaType;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
title?: string;
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
public tmdbId: number;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
})
|
||||
user: User;
|
||||
|
||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public media: Media;
|
||||
|
||||
@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> {
|
||||
const blacklist = new this({
|
||||
...blacklistRequest,
|
||||
});
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
},
|
||||
});
|
||||
|
||||
const blacklistRepository = getRepository(this);
|
||||
|
||||
await blacklistRepository.save(blacklist);
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
status: MediaStatus.BLACKLISTED,
|
||||
status4k: MediaStatus.BLACKLISTED,
|
||||
mediaType: blacklistRequest.mediaType,
|
||||
blacklist: blacklist,
|
||||
});
|
||||
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
media.blacklist = blacklist;
|
||||
media.status = MediaStatus.BLACKLISTED;
|
||||
media.status4k = MediaStatus.BLACKLISTED;
|
||||
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +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 type { User } from '@server/entity/User';
|
||||
import { Watchlist } from '@server/entity/Watchlist';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
@@ -66,7 +68,7 @@ class Media {
|
||||
|
||||
try {
|
||||
const media = await mediaRepository.findOne({
|
||||
where: { tmdbId: id, mediaType },
|
||||
where: { tmdbId: id, mediaType: mediaType },
|
||||
relations: { requests: true, issues: true },
|
||||
});
|
||||
|
||||
@@ -116,6 +118,11 @@ class Media {
|
||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||
public issues: Issue[];
|
||||
|
||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
||||
eager: true,
|
||||
})
|
||||
public blacklist: Blacklist;
|
||||
|
||||
@CreateDateColumn()
|
||||
public createdAt: Date;
|
||||
|
||||
|
||||
@@ -40,6 +40,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 {}
|
||||
|
||||
type MediaRequestOptions = {
|
||||
isAutoRequest?: boolean;
|
||||
@@ -143,6 +144,16 @@ export class MediaRequest {
|
||||
mediaType: requestBody.mediaType,
|
||||
});
|
||||
} else {
|
||||
if (media.status === MediaStatus.BLACKLISTED) {
|
||||
logger.warn('Request for media blocked due to being blacklisted', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
label: 'Media Request',
|
||||
});
|
||||
|
||||
throw new BlacklistedMediaError('This media is blacklisted.');
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||
media.status = MediaStatus.PENDING;
|
||||
}
|
||||
|
||||
14
server/interfaces/api/blacklistInterfaces.ts
Normal file
14
server/interfaces/api/blacklistInterfaces.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||
|
||||
export interface BlacklistItem {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title?: string;
|
||||
createdAt?: Date;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||
results: BlacklistItem[];
|
||||
}
|
||||
@@ -27,6 +27,8 @@ export enum Permission {
|
||||
AUTO_REQUEST_TV = 33554432,
|
||||
RECENT_VIEW = 67108864,
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
MANAGE_BLACKLIST = 268435456,
|
||||
VIEW_BLACKLIST = 1073741824,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
20
server/migration/1699901142442-AddBlacklist.ts
Normal file
20
server/migration/1699901142442-AddBlacklist.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBlacklist1699901142442 implements MigrationInterface {
|
||||
name = 'AddBlacklist1699901142442';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')),"userId" integer, "mediaId" integer,CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId", "userId"))`
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") `
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`);
|
||||
}
|
||||
}
|
||||
148
server/routes/blacklist.ts
Normal file
148
server/routes/blacklist.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
import Media from '@server/entity/Media';
|
||||
import { NotFoundError } from '@server/entity/Watchlist';
|
||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||
import { Permission } from '@server/lib/permissions';
|
||||
import logger from '@server/logger';
|
||||
import { isAuthenticated } from '@server/middleware/auth';
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const blacklistRoutes = Router();
|
||||
|
||||
export const blacklistAdd = z.object({
|
||||
tmdbId: z.coerce.number(),
|
||||
mediaType: z.nativeEnum(MediaType),
|
||||
title: z.coerce.string().optional(),
|
||||
user: z.coerce.number(),
|
||||
});
|
||||
|
||||
blacklistRoutes.get(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||
type: 'or',
|
||||
}),
|
||||
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
||||
async (req, res, next) => {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
const search = (req.query.search as string) ?? '';
|
||||
|
||||
try {
|
||||
let query = getRepository(Blacklist)
|
||||
.createQueryBuilder('blacklist')
|
||||
.leftJoinAndSelect('blacklist.user', 'user');
|
||||
|
||||
if (search.length > 0) {
|
||||
query = query.where('blacklist.title like :title', {
|
||||
title: `%${search}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const [blacklistedItems, itemsCount] = await query
|
||||
.orderBy('blacklist.createdAt', 'DESC')
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(itemsCount / pageSize),
|
||||
pageSize,
|
||||
results: itemsCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: blacklistedItems,
|
||||
} as BlacklistResultsResponse);
|
||||
} catch (error) {
|
||||
logger.error('Something went wrong while retrieving blacklisted items', {
|
||||
label: 'Blacklist',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Unable to retrieve blacklisted items.',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
blacklistRoutes.post(
|
||||
'/',
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const values = blacklistAdd.parse(req.body);
|
||||
|
||||
await Blacklist.addToBlacklist({
|
||||
blacklistRequest: values,
|
||||
});
|
||||
|
||||
return res.status(201).send();
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof QueryFailedError) {
|
||||
switch (error.driverError.errno) {
|
||||
case 19:
|
||||
return next({ status: 412, message: 'Item already blacklisted' });
|
||||
default:
|
||||
logger.warn('Something wrong with data blacklist', {
|
||||
tmdbId: req.body.tmdbId,
|
||||
mediaType: req.body.mediaType,
|
||||
label: 'Blacklist',
|
||||
});
|
||||
return next({ status: 409, message: 'Something wrong' });
|
||||
}
|
||||
}
|
||||
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
blacklistRoutes.delete(
|
||||
'/:id',
|
||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||
type: 'or',
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const blacklisteRepository = getRepository(Blacklist);
|
||||
|
||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||
where: { tmdbId: Number(req.params.id) },
|
||||
});
|
||||
|
||||
await blacklisteRepository.remove(blacklistItem);
|
||||
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const mediaItem = await mediaRepository.findOneOrFail({
|
||||
where: { tmdbId: Number(req.params.id) },
|
||||
});
|
||||
|
||||
await mediaRepository.remove(mediaItem);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
if (e instanceof NotFoundError) {
|
||||
return next({
|
||||
status: 401,
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
return next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default blacklistRoutes;
|
||||
@@ -23,6 +23,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 collectionRoutes from './collection';
|
||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||
import issueRoutes from './issue';
|
||||
@@ -144,6 +145,7 @@ 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('/movie', isAuthenticated(), movieRoutes);
|
||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import {
|
||||
BlacklistedMediaError,
|
||||
DuplicateMediaRequestError,
|
||||
MediaRequest,
|
||||
NoSeasonsAvailableError,
|
||||
@@ -243,6 +244,8 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
||||
return next({ status: 409, message: error.message });
|
||||
case NoSeasonsAvailableError:
|
||||
return next({ status: 202, message: error.message });
|
||||
case BlacklistedMediaError:
|
||||
return next({ status: 403, message: error.message });
|
||||
default:
|
||||
return next({ status: 500, message: error.message });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user