From 6aea2afcc71a14de565e89b35a72a6de9fb800c8 Mon Sep 17 00:00:00 2001 From: JoaquinOlivero Date: Mon, 22 Jan 2024 23:31:40 +0000 Subject: [PATCH] feat: blacklist items from Discover page fix #490 --- overseerr-api.yml | 28 ++++ server/entity/Blacklist.ts | 53 ++++++ server/interfaces/api/blacklistAdd.ts | 8 + server/interfaces/api/discoverInterfaces.ts | 6 + .../migration/1699901142442-AddBlacklist.ts | 15 ++ server/routes/blacklist.ts | 48 ++++++ server/routes/discover.ts | 152 ++++++++++++------ server/routes/index.ts | 2 + src/components/BlacklistModal/index.tsx | 37 +++++ src/components/TitleCard/index.tsx | 97 ++++++++++- 10 files changed, 393 insertions(+), 53 deletions(-) create mode 100644 server/entity/Blacklist.ts create mode 100644 server/interfaces/api/blacklistAdd.ts create mode 100644 server/migration/1699901142442-AddBlacklist.ts create mode 100644 server/routes/blacklist.ts create mode 100644 src/components/BlacklistModal/index.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index 7a7ea490a..8778ac64e 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -38,6 +38,8 @@ tags: description: Endpoints related to getting service (Radarr/Sonarr) details. - name: watchlist description: Collection of media to watch later + - name: blacklist + description: Blacklisted media from discovery page. servers: - url: '{server}/api/v1' variables: @@ -46,6 +48,16 @@ servers: components: schemas: + Blacklist: + type: object + properties: + tmdbId: + type: number + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' Watchlist: type: object properties: @@ -4027,6 +4039,22 @@ paths: restricted: type: boolean example: false + /blacklist: + post: + summary: Add media to blacklist + tags: + - blacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Blacklist' + responses: + '201': + description: Item succesfully blacklisted + '412': + description: Item has already been blacklisted /watchlist: post: summary: Add media to watchlist diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts new file mode 100644 index 000000000..ee2386a40 --- /dev/null +++ b/server/entity/Blacklist.ts @@ -0,0 +1,53 @@ +import type { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import type { BlacklistItem } from '@server/interfaces/api/discoverInterfaces'; +import { + Column, + CreateDateColumn, + Entity, + 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() + public tmdbId: number; + + @CreateDateColumn() + public createdAt: Date; + + constructor(init?: Partial) { + Object.assign(this, init); + } + + public static async addToBlacklist({ + blacklistRequest, + }: { + blacklistRequest: { + mediaType: MediaType; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + }; + }): Promise { + const blacklistRepository = getRepository(this); + + const blacklistItem = new this({ + ...blacklistRequest, + }); + + await blacklistRepository.save(blacklistItem); + return; + } +} diff --git a/server/interfaces/api/blacklistAdd.ts b/server/interfaces/api/blacklistAdd.ts new file mode 100644 index 000000000..d7eca77f1 --- /dev/null +++ b/server/interfaces/api/blacklistAdd.ts @@ -0,0 +1,8 @@ +import { MediaType } from '@server/constants/media'; +import { z } from 'zod'; + +export const blacklistAdd = z.object({ + tmdbId: z.coerce.number(), + mediaType: z.nativeEnum(MediaType), + title: z.coerce.string().optional(), +}); diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index 89cb7426f..47b5e7b3c 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -17,3 +17,9 @@ export interface WatchlistResponse { totalResults: number; results: WatchlistItem[]; } + +export interface BlacklistItem { + tmdbId: number; + mediaType: 'movie' | 'tv'; + title?: string; +} diff --git a/server/migration/1699901142442-AddBlacklist.ts b/server/migration/1699901142442-AddBlacklist.ts new file mode 100644 index 000000000..9f82c6f20 --- /dev/null +++ b/server/migration/1699901142442-AddBlacklist.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlacklist1699901142442 implements MigrationInterface { + name = 'AddBlacklist1699901142442'; + + public async up(queryRunner: QueryRunner): Promise { + 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')), CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"))` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "blacklist"`); + } +} diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts new file mode 100644 index 000000000..3f166f372 --- /dev/null +++ b/server/routes/blacklist.ts @@ -0,0 +1,48 @@ +import { Blacklist } from '@server/entity/Blacklist'; +import { blacklistAdd } from '@server/interfaces/api/blacklistAdd'; +import logger from '@server/logger'; +import { Router } from 'express'; +import { QueryFailedError } from 'typeorm'; + +const blacklistRoutes = Router(); + +blacklistRoutes.post('/', async (req, res, next) => { + try { + if (!req.user) { + return next({ + status: 401, + message: 'You must be logged in to blacklist an item.', + }); + } + + 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 }); + } +}); + +export default blacklistRoutes; diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e9647895a..fe9472a8b 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -4,6 +4,7 @@ import TheMovieDb from '@server/api/themoviedb'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import { Blacklist } from '@server/entity/Blacklist'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; @@ -78,6 +79,14 @@ export type FilterOptions = z.infer; discoverRoutes.get('/movies', async (req, res, next) => { const tmdb = createTmdbWithRegionLanguage(req.user); + const blacklistedItems = await getRepository(Blacklist).find({ + select: { + tmdbId: true, + }, + }); + + const toRemove = new Set(blacklistedItems.map((i) => i.tmdbId)); + try { const query = QueryFilterOptions.parse(req.query); const keywords = query.keywords; @@ -126,15 +135,17 @@ discoverRoutes.get('/movies', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (req) => - req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + results: data.results + .filter((item) => !toRemove.has(item.id)) + .map((result) => + mapMovieResult( + result, + media.find( + (req) => + req.tmdbId === result.id && req.mediaType === MediaType.MOVIE + ) ) - ) - ), + ), }); } catch (e) { logger.debug('Something went wrong retrieving popular movies', { @@ -326,6 +337,14 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { primaryReleaseDateGte: date, }); + const blacklistedItems = await getRepository(Blacklist).find({ + select: { + tmdbId: true, + }, + }); + + const toRemove = new Set(blacklistedItems.map((i) => i.tmdbId)); + const media = await Media.getRelatedMedia( req.user, data.results.map((result) => result.id) @@ -335,15 +354,17 @@ discoverRoutes.get('/movies/upcoming', async (req, res, next) => { page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => - mapMovieResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + results: data.results + .filter((item) => !toRemove.has(item.id)) + .map((result) => + mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + ) ) - ) - ), + ), }); } catch (e) { logger.debug('Something went wrong retrieving upcoming movies', { @@ -387,6 +408,14 @@ discoverRoutes.get('/tv', async (req, res, next) => { watchRegion: query.watchRegion, }); + const blacklistedItems = await getRepository(Blacklist).find({ + select: { + tmdbId: true, + }, + }); + + const toRemove = new Set(blacklistedItems.map((i) => i.tmdbId)); + const media = await Media.getRelatedMedia( req.user, data.results.map((result) => result.id) @@ -408,14 +437,17 @@ discoverRoutes.get('/tv', async (req, res, next) => { totalPages: data.total_pages, totalResults: data.total_results, keywords: keywordData, - results: data.results.map((result) => - mapTvResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + results: data.results + .filter((item) => !toRemove.has(item.id)) + .map((result) => + mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) - ) - ), + ), }); } catch (e) { logger.debug('Something went wrong retrieving popular series', { @@ -607,6 +639,14 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { firstAirDateGte: date, }); + const blacklistedItems = await getRepository(Blacklist).find({ + select: { + tmdbId: true, + }, + }); + + const toRemove = new Set(blacklistedItems.map((i) => i.tmdbId)); + const media = await Media.getRelatedMedia( req.user, data.results.map((result) => result.id) @@ -616,14 +656,17 @@ discoverRoutes.get('/tv/upcoming', async (req, res, next) => { page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => - mapTvResult( - result, - media.find( - (med) => med.tmdbId === result.id && med.mediaType === MediaType.TV + results: data.results + .filter((item) => !toRemove.has(item.id)) + .map((result) => + mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) - ) - ), + ), }); } catch (e) { logger.debug('Something went wrong retrieving upcoming series', { @@ -646,6 +689,14 @@ discoverRoutes.get('/trending', async (req, res, next) => { language: req.locale ?? (req.query.language as string), }); + const blacklistedItems = await getRepository(Blacklist).find({ + select: { + tmdbId: true, + }, + }); + + const toRemove = new Set(blacklistedItems.map((i) => i.tmdbId)); + const media = await Media.getRelatedMedia( req.user, data.results.map((result) => result.id) @@ -655,27 +706,30 @@ discoverRoutes.get('/trending', async (req, res, next) => { page: data.page, totalPages: data.total_pages, totalResults: data.total_results, - results: data.results.map((result) => - isMovie(result) - ? mapMovieResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.MOVIE + results: data.results + .filter((item) => !toRemove.has(item.id)) + .map((result) => + isMovie(result) + ? mapMovieResult( + result, + media.find( + (med) => + med.tmdbId === result.id && + med.mediaType === MediaType.MOVIE + ) ) - ) - : isPerson(result) - ? mapPersonResult(result) - : isCollection(result) - ? mapCollectionResult(result) - : mapTvResult( - result, - media.find( - (med) => - med.tmdbId === result.id && med.mediaType === MediaType.TV + : isPerson(result) + ? mapPersonResult(result) + : isCollection(result) + ? mapCollectionResult(result) + : mapTvResult( + result, + media.find( + (med) => + med.tmdbId === result.id && med.mediaType === MediaType.TV + ) ) - ) - ), + ), }); } catch (e) { logger.debug('Something went wrong retrieving trending items', { diff --git a/server/routes/index.ts b/server/routes/index.ts index 77d2c89a5..48a3f4895 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -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(Permission.ADMIN), blacklistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx new file mode 100644 index 000000000..b505b7ffe --- /dev/null +++ b/src/components/BlacklistModal/index.tsx @@ -0,0 +1,37 @@ +import Modal from '@app/components/Common/Modal'; +import { Transition } from '@headlessui/react'; + +interface BlacklistModalProps { + title: string; + onComplete?: () => void; + onCancel?: () => void; +} + +const BlacklistModal = ({ + title, + onComplete, + onCancel, +}: BlacklistModalProps) => { + return ( + + + + ); +}; + +export default BlacklistModal; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 30a62c16e..191a7220d 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -1,4 +1,5 @@ import Spinner from '@app/assets/spinner.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import StatusBadgeMini from '@app/components/Common/StatusBadgeMini'; @@ -12,16 +13,18 @@ import { withProperties } from '@app/utils/typeHelpers'; import { Transition } from '@headlessui/react'; import { ArrowDownTrayIcon, + EyeSlashIcon, MinusCircleIcon, StarIcon, } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; +import type { Blacklist } from '@server/entity/Blacklist'; import type { Watchlist } from '@server/entity/Watchlist'; import type { MediaType } from '@server/models/Search'; import axios from 'axios'; import Link from 'next/link'; import type React from 'react'; -import { Fragment, useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import { mutate } from 'swr'; @@ -49,6 +52,10 @@ const messages = defineMessages({ '{title} Removed from watchlist successfully!', watchlistCancel: 'watchlist for {title} canceled.', watchlistError: 'Something went wrong try again.', + blacklistSuccess: '{title} was successfully blacklisted.', + blacklistError: 'Something went wrong try again.', + blacklistDuplicateError: + '{title} has already been blacklisted.', }); const TitleCard = ({ @@ -75,6 +82,8 @@ const TitleCard = ({ const [toggleWatchlist, setToggleWatchlist] = useState( !isAddedToWatchlist ); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); + const cardRef = useRef(null); // Just to get the year from the date if (year) { @@ -157,7 +166,63 @@ const TitleCard = ({ } }; + const onClickHideItemBtn = async (): Promise => { + const topNode = cardRef.current; + + if (topNode) { + try { + const response = await axios.post('/api/v1/blacklist', { + tmdbId: id, + mediaType, + title, + }); + if (response.status === 201) { + topNode.parentElement?.classList.add('hidden'); + addToast( + + {intl.formatMessage(messages.blacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + closeBlacklistModal(); + } catch (e) { + if (e.response.status === 412) { + topNode.parentElement?.classList.add('hidden'); + addToast( + + {intl.formatMessage(messages.blacklistDuplicateError, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(messages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } finally { + closeBlacklistModal(); + } + } else { + addToast(intl.formatMessage(messages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + const closeModal = useCallback(() => setShowRequestModal(false), []); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); const showRequestButton = hasPermission( [ @@ -169,10 +234,13 @@ const TitleCard = ({ { type: 'or' } ); + const showHideButton = hasPermission([Permission.ADMIN]); + return (
+ {showBlacklistModal && ( + + )}
{showDetail && ( - <> +
{toggleWatchlist ? ( )} - + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + + )} +
)} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && (