diff --git a/overseerr-api.yml b/overseerr-api.yml index d24035380..f5e1d1622 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,19 @@ servers: components: schemas: + Blacklist: + type: object + properties: + tmdbId: + type: number + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + userId: + type: number + example: 1 Watchlist: type: object properties: @@ -4042,6 +4057,94 @@ paths: restricted: type: boolean example: false + /blacklist: + get: + summary: Returns blacklisted items + description: Returns list of all blacklisted media + tags: + - settings + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 25 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: search + schema: + type: string + nullable: true + example: dune + responses: + '200': + description: Blacklisted items returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + properties: + user: + $ref: '#/components/schemas/User' + createdAt: + type: string + example: 2024-04-21T01:55:44.000Z + id: + type: number + example: 1 + mediaType: + type: string + example: movie + title: + type: string + example: Dune + tmdbId: + type: number + example: 438631 + 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 + /blacklist/{tmdbId}: + delete: + summary: Remove media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item /watchlist: post: summary: Add media to watchlist diff --git a/server/constants/media.ts b/server/constants/media.ts index de2bf834d..dbcfbd347 100644 --- a/server/constants/media.ts +++ b/server/constants/media.ts @@ -16,4 +16,5 @@ export enum MediaStatus { PROCESSING, PARTIALLY_AVAILABLE, AVAILABLE, + BLACKLISTED, } diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts new file mode 100644 index 000000000..5e24419dc --- /dev/null +++ b/server/entity/Blacklist.ts @@ -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) { + Object.assign(this, init); + } + + public static async addToBlacklist({ + blacklistRequest, + }: { + blacklistRequest: { + mediaType: MediaType; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + }; + }): Promise { + 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); + } + } +} diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 723eb213d..4f64178a7 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -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; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7be..6b2c7b56e 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -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; } diff --git a/server/interfaces/api/blacklistInterfaces.ts b/server/interfaces/api/blacklistInterfaces.ts new file mode 100644 index 000000000..99e56585c --- /dev/null +++ b/server/interfaces/api/blacklistInterfaces.ts @@ -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[]; +} diff --git a/server/lib/permissions.ts b/server/lib/permissions.ts index 4a4a90d84..bc477169c 100644 --- a/server/lib/permissions.ts +++ b/server/lib/permissions.ts @@ -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 { diff --git a/server/migration/1699901142442-AddBlacklist.ts b/server/migration/1699901142442-AddBlacklist.ts new file mode 100644 index 000000000..eb0962707 --- /dev/null +++ b/server/migration/1699901142442-AddBlacklist.ts @@ -0,0 +1,20 @@ +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')),"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 { + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query(`DROP INDEX "IDX_6bbafa28411e6046421991ea21"`); + } +} diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts new file mode 100644 index 000000000..4a07a4998 --- /dev/null +++ b/server/routes/blacklist.ts @@ -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; diff --git a/server/routes/index.ts b/server/routes/index.ts index 12434256e..c7c8389e0 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(), blacklistRoutes); router.use('/movie', isAuthenticated(), movieRoutes); router.use('/tv', isAuthenticated(), tvRoutes); router.use('/media', isAuthenticated(), mediaRoutes); diff --git a/server/routes/request.ts b/server/routes/request.ts index 94ae8384a..320f149b5 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -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( 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 }); } diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx new file mode 100644 index 000000000..217f4cefd --- /dev/null +++ b/src/components/Blacklist/index.tsx @@ -0,0 +1,417 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import CachedImage from '@app/components/Common/CachedImage'; +import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Header from '@app/components/Common/Header'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import PageTitle from '@app/components/Common/PageTitle'; +import useDebouncedState from '@app/hooks/useDebouncedState'; +import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams'; +import { Permission, useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import Error from '@app/pages/_error'; +import defineMessages from '@app/utils/defineMessages'; +import { + ChevronLeftIcon, + ChevronRightIcon, + MagnifyingGlassIcon, + TrashIcon, +} from '@heroicons/react/24/solid'; +import type { + BlacklistItem, + BlacklistResultsResponse, +} from '@server/interfaces/api/blacklistInterfaces'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import type { ChangeEvent } from 'react'; +import { useState } from 'react'; +import { useInView } from 'react-intersection-observer'; +import { FormattedRelativeTime, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; + +const messages = defineMessages('components.Blacklist', { + blacklistsettings: 'Blacklist Settings', + blacklistSettingsDescription: 'Manage blacklisted media.', + mediaName: 'Name', + mediaType: 'Type', + mediaTmdbId: 'tmdb Id', + blacklistdate: 'date', + blacklistedby: '{date} by {user}', + blacklistNotFoundError: '{title} is not blacklisted.', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const Blacklist = () => { + const [currentPageSize, setCurrentPageSize] = useState(10); + const [searchFilter, debouncedSearchFilter, setSearchFilter] = + useDebouncedState(''); + const router = useRouter(); + const intl = useIntl(); + + const page = router.query.page ? Number(router.query.page) : 1; + const pageIndex = page - 1; + const updateQueryParams = useUpdateQueryParams({ page: page.toString() }); + + const { + data, + error, + mutate: revalidate, + } = useSWR( + `/api/v1/blacklist/?take=${currentPageSize} + &skip=${pageIndex * currentPageSize} + ${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`, + { + refreshInterval: 0, + revalidateOnFocus: false, + } + ); + + // check if there's no data and no errors in the table + // so as to show a spinner inside the table and not refresh the whole component + if (!data && error) { + return ; + } + + const searchItem = (e: ChangeEvent) => { + // Remove the "page" query param from the URL + // so that the "skip" query param on line 62 is empty + // and the search returns results without skipping items + if (router.query.page) router.replace(router.basePath); + + setSearchFilter(e.target.value as string); + }; + + const hasNextPage = data && data.pageInfo.pages > pageIndex + 1; + const hasPrevPage = pageIndex > 0; + + return ( + <> + +
{intl.formatMessage(globalMessages.blacklist)}
+ +
+
+ + + + searchItem(e)} + /> +
+
+ + {!data ? ( + + ) : data.results.length === 0 ? ( +
+ + {intl.formatMessage(globalMessages.noresults)} + +
+ ) : ( + data.results.map((item: BlacklistItem) => { + return ( +
+ +
+ ); + }) + )} + +
+ +
+ + ); +}; + +export default Blacklist; + +interface BlacklistedItemProps { + item: BlacklistItem; + revalidateList: () => void; +} + +const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { + const [isUpdating, setIsUpdating] = useState(false); + const { addToast } = useToasts(); + const { ref, inView } = useInView({ + triggerOnce: true, + }); + const intl = useIntl(); + const { hasPermission } = useUser(); + + const url = + item.mediaType === 'movie' + ? `/api/v1/movie/${item.tmdbId}` + : `/api/v1/tv/${item.tmdbId}`; + const { data: title, error } = useSWR( + inView ? url : null + ); + + if (!title && !error) { + return ( +
+ ); + } + + const removeFromBlacklist = async (tmdbId: number, title?: string) => { + setIsUpdating(true); + + const res = await fetch('/api/v1/blacklist/' + tmdbId, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + revalidateList(); + setIsUpdating(false); + }; + + return ( +
+ {title && title.backdropPath && ( +
+ +
+
+ )} +
+
+ + + +
+
+ {title && + (isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)} +
+ + + {title && (isMovie(title) ? title.title : title.name)} + + +
+
+ +
+
+ Status + + {intl.formatMessage(globalMessages.blacklisted)} + +
+ + {item.createdAt && ( +
+ + {intl.formatMessage(globalMessages.blacklisted)} + + + {intl.formatMessage(messages.blacklistedby, { + date: ( + + ), + user: ( + + + + + {item.user.displayName} + + + + ), + })} + +
+ )} +
+ {item.mediaType === 'movie' ? ( +
+
+ {intl.formatMessage(globalMessages.movie)} +
+
+ ) : ( +
+
+ {intl.formatMessage(globalMessages.tvshow)} +
+
+ )} +
+
+
+
+ {hasPermission(Permission.MANAGE_BLACKLIST) && ( + + removeFromBlacklist( + item.tmdbId, + title && (isMovie(title) ? title.title : title.name) + ) + } + confirmText={intl.formatMessage( + isUpdating ? globalMessages.deleting : globalMessages.areyousure + )} + className={`w-full ${ + isUpdating ? 'pointer-events-none opacity-50' : '' + }`} + > + + + {intl.formatMessage(globalMessages.removefromBlacklist)} + + + )} +
+
+ ); +}; diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx new file mode 100644 index 000000000..0908d3735 --- /dev/null +++ b/src/components/BlacklistBlock/index.tsx @@ -0,0 +1,129 @@ +import Badge from '@app/components/Common/Badge'; +import Button from '@app/components/Common/Button'; +import Tooltip from '@app/components/Common/Tooltip'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid'; +import type { Blacklist } from '@server/entity/Blacklist'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; + +const messages = defineMessages('component.BlacklistBlock', { + blacklistedby: 'Blacklisted By', + blacklistdate: 'Blacklisted date', +}); + +interface BlacklistBlockProps { + blacklistItem: Blacklist; + onUpdate?: () => void; + onDelete?: () => void; +} + +const BlacklistBlock = ({ + blacklistItem, + onUpdate, + onDelete, +}: BlacklistBlockProps) => { + const { user } = useUser(); + const intl = useIntl(); + const [isUpdating, setIsUpdating] = useState(false); + const { addToast } = useToasts(); + + const removeFromBlacklist = async (tmdbId: number, title?: string) => { + setIsUpdating(true); + + const res = await fetch('/api/v1/blacklist/' + tmdbId, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + onUpdate && onUpdate(); + onDelete && onDelete(); + + setIsUpdating(false); + }; + + return ( +
+
+
+
+ + + + + + + {blacklistItem.user.displayName} + + + +
+
+
+ + + +
+
+
+
+
+ + {intl.formatMessage(globalMessages.blacklisted)} + +
+
+
+ + + + + {intl.formatDate(blacklistItem.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + +
+
+
+ ); +}; + +export default BlacklistBlock; diff --git a/src/components/BlacklistModal/index.tsx b/src/components/BlacklistModal/index.tsx new file mode 100644 index 000000000..aeca8d411 --- /dev/null +++ b/src/components/BlacklistModal/index.tsx @@ -0,0 +1,79 @@ +import Modal from '@app/components/Common/Modal'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import type { MovieDetails } from '@server/models/Movie'; +import type { TvDetails } from '@server/models/Tv'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +interface BlacklistModalProps { + tmdbId: number; + type: 'movie' | 'tv' | 'collection'; + show: boolean; + onComplete?: () => void; + onCancel?: () => void; + isUpdating?: boolean; +} + +const messages = defineMessages('component.BlacklistModal', { + blacklisting: 'Blacklisting', +}); + +const isMovie = ( + movie: MovieDetails | TvDetails | undefined +): movie is MovieDetails => { + if (!movie) return false; + return (movie as MovieDetails).title !== undefined; +}; + +const BlacklistModal = ({ + tmdbId, + type, + show, + onComplete, + onCancel, + isUpdating, +}: BlacklistModalProps) => { + const intl = useIntl(); + + const { data, error } = useSWR( + `/api/v1/${type}/${tmdbId}` + ); + + return ( + + + + ); +}; + +export default BlacklistModal; diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 7afa28e4e..9e8ab32ad 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -183,6 +183,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); } + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return (
{ sliderKey="collection-movies" isLoading={false} isEmpty={data.parts.length === 0} - items={data.parts.map((title) => ( - - ))} + items={data.parts + .filter((title) => { + if (!blacklistVisibility) + return title.mediaInfo?.status !== MediaStatus.BLACKLISTED; + return title; + }) + .map((title) => ( + + ))} />
diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 46c946ae2..f1c3bf66c 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -1,8 +1,10 @@ import PersonCard from '@app/components/PersonCard'; import TitleCard from '@app/components/TitleCard'; import TmdbTitleCard from '@app/components/TitleCard/TmdbTitleCard'; +import { Permission, useUser } from '@app/hooks/useUser'; import useVerticalScroll from '@app/hooks/useVerticalScroll'; import globalMessages from '@app/i18n/globalMessages'; +import { MediaStatus } from '@server/constants/media'; import type { WatchlistItem } from '@server/interfaces/api/discoverInterfaces'; import type { CollectionResult, @@ -32,7 +34,14 @@ const ListView = ({ mutateParent, }: ListViewProps) => { const intl = useIntl(); + const { hasPermission } = useUser(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); + + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return ( <> {isEmpty && ( @@ -55,76 +64,89 @@ const ListView = ({ ); })} - {items?.map((title, index) => { - let titleCard: React.ReactNode; - - switch (title.mediaType) { - case 'movie': - titleCard = ( - 0 - } - canExpand - /> - ); - break; - case 'tv': - titleCard = ( - 0 - } - canExpand - /> + {items + ?.filter((title) => { + if (!blacklistVisibility) + return ( + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED ); - break; - case 'collection': - titleCard = ( - - ); - break; - case 'person': - titleCard = ( - - ); - break; - } + return title; + }) + .map((title, index) => { + let titleCard: React.ReactNode; - return
  • {titleCard}
  • ; - })} + switch (title.mediaType) { + case 'movie': + titleCard = ( + 0 + } + canExpand + /> + ); + break; + case 'tv': + titleCard = ( + 0 + } + canExpand + /> + ); + break; + case 'collection': + titleCard = ( + + ); + break; + case 'person': + titleCard = ( + + ); + break; + } + + return
  • {titleCard}
  • ; + })} {isLoading && !isReachingEnd && [...Array(20)].map((_item, i) => ( diff --git a/src/components/Common/StatusBadgeMini/index.tsx b/src/components/Common/StatusBadgeMini/index.tsx index a7e24a378..afcd72bfc 100644 --- a/src/components/Common/StatusBadgeMini/index.tsx +++ b/src/components/Common/StatusBadgeMini/index.tsx @@ -1,6 +1,11 @@ import Spinner from '@app/assets/spinner.svg'; import { CheckCircleIcon } from '@heroicons/react/20/solid'; -import { BellIcon, ClockIcon, MinusSmallIcon } from '@heroicons/react/24/solid'; +import { + BellIcon, + ClockIcon, + EyeSlashIcon, + MinusSmallIcon, +} from '@heroicons/react/24/solid'; import { MediaStatus } from '@server/constants/media'; interface StatusBadgeMiniProps { @@ -44,6 +49,10 @@ const StatusBadgeMini = ({ ); indicatorIcon = ; break; + case MediaStatus.BLACKLISTED: + badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white'); + indicatorIcon = ; + break; case MediaStatus.PARTIALLY_AVAILABLE: badgeStyle.push( 'bg-green-500 border-green-400 ring-green-400 text-green-100' diff --git a/src/components/Layout/Sidebar/index.tsx b/src/components/Layout/Sidebar/index.tsx index d9b7d3fbc..a947e2626 100644 --- a/src/components/Layout/Sidebar/index.tsx +++ b/src/components/Layout/Sidebar/index.tsx @@ -8,6 +8,7 @@ import { ClockIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, SparklesIcon, TvIcon, @@ -25,6 +26,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', { browsemovies: 'Movies', browsetv: 'Series', requests: 'Requests', + blacklist: 'Blacklist', issues: 'Issues', users: 'Users', settings: 'Settings', @@ -71,6 +73,17 @@ const SidebarLinks: SidebarLinkProps[] = [ svgIcon: , activeRegExp: /^\/requests/, }, + { + href: '/blacklist', + messagesKey: 'blacklist', + svgIcon: , + activeRegExp: /^\/blacklist/, + requiredPermission: [ + Permission.MANAGE_BLACKLIST, + Permission.VIEW_BLACKLIST, + ], + permissionType: 'or', + }, { href: '/issues', messagesKey: 'issues', diff --git a/src/components/ManageSlideOver/index.tsx b/src/components/ManageSlideOver/index.tsx index b669ebb43..0f96aa202 100644 --- a/src/components/ManageSlideOver/index.tsx +++ b/src/components/ManageSlideOver/index.tsx @@ -1,3 +1,4 @@ +import BlacklistBlock from '@app/components/BlacklistBlock'; import Button from '@app/components/Common/Button'; import ConfirmButton from '@app/components/Common/ConfirmButton'; import SlideOver from '@app/components/Common/SlideOver'; @@ -284,6 +285,20 @@ const ManageSlideOver = ({
    )} + {data.mediaInfo?.status === MediaStatus.BLACKLISTED && ( +
    +

    + {intl.formatMessage(globalMessages.blacklist)} +

    +
    + revalidate()} + onDelete={() => onClose()} + /> +
    +
    + )} {hasPermission(Permission.ADMIN) && (data.mediaInfo?.serviceUrl || data.mediaInfo?.tautulliUrl || @@ -603,32 +618,17 @@ const ManageSlideOver = ({
    )} - {hasPermission(Permission.ADMIN) && data?.mediaInfo && ( -
    -

    - {intl.formatMessage(messages.manageModalAdvanced)} -

    -
    - {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( - - )} - {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( + {hasPermission(Permission.ADMIN) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLACKLISTED && ( +
    +

    + {intl.formatMessage(messages.manageModalAdvanced)} +

    +
    + {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( )} -
    - deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - - - {intl.formatMessage(messages.manageModalClearMedia)} - - -
    - {intl.formatMessage(messages.manageModalClearMediaWarning, { - mediaType: intl.formatMessage( - mediaType === 'movie' ? messages.movie : messages.tvshow - ), - mediaServerName: - settings.currentSettings.mediaServerType === - MediaServerType.EMBY - ? 'Emby' - : settings.currentSettings.mediaServerType === - MediaServerType.PLEX - ? 'Plex' - : 'Jellyfin', - })} + {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && + settings.currentSettings.series4kEnabled && ( + + )} +
    + deleteMedia()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + + + {intl.formatMessage(messages.manageModalClearMedia)} + + +
    + {intl.formatMessage(messages.manageModalClearMediaWarning, { + mediaType: intl.formatMessage( + mediaType === 'movie' ? messages.movie : messages.tvshow + ), + mediaServerName: + settings.currentSettings.mediaServerType === + MediaServerType.EMBY + ? 'Emby' + : settings.currentSettings.mediaServerType === + MediaServerType.PLEX + ? 'Plex' + : 'Jellyfin', + })} +
    -
    - )} + )}
    ); diff --git a/src/components/MediaSlider/index.tsx b/src/components/MediaSlider/index.tsx index 56e0afc80..006f0df92 100644 --- a/src/components/MediaSlider/index.tsx +++ b/src/components/MediaSlider/index.tsx @@ -3,8 +3,10 @@ import PersonCard from '@app/components/PersonCard'; import Slider from '@app/components/Slider'; import TitleCard from '@app/components/TitleCard'; import useSettings from '@app/hooks/useSettings'; +import { useUser } from '@app/hooks/useUser'; import { ArrowRightCircleIcon } from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; +import { Permission } from '@server/lib/permissions'; import type { MovieResult, PersonResult, @@ -41,6 +43,7 @@ const MediaSlider = ({ onNewTitles, }: MediaSliderProps) => { const settings = useSettings(); + const { hasPermission } = useUser(); const { data, error, setSize, size } = useSWRInfinite( (pageIndex: number, previousPageData: MixedResult | null) => { if (previousPageData && pageIndex + 1 > previousPageData.totalPages) { @@ -90,50 +93,65 @@ const MediaSlider = ({ return null; } - const finalTitles = titles.slice(0, 20).map((title) => { - switch (title.mediaType) { - case 'movie': - return ( - 0} - /> - ); - case 'tv': - return ( - 0} - /> - ); - case 'person': + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + + const finalTitles = titles + .slice(0, 20) + .filter((title) => { + if (!blacklistVisibility) return ( - + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED ); - } - }); + return title; + }) + .map((title) => { + switch (title.mediaType) { + case 'movie': + return ( + 0} + /> + ); + case 'tv': + return ( + 0} + /> + ); + case 'person': + return ( + + ); + } + }); if (linkUrl && titles.length > 20) { finalTitles.push( diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index e4bc991ef..c6583e3df 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -5,6 +5,7 @@ import RTRotten from '@app/assets/rt_rotten.svg'; import ImdbLogo from '@app/assets/services/imdb.svg'; import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -35,6 +36,7 @@ import { CloudIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, PlayIcon, TicketIcon, @@ -55,7 +57,7 @@ import 'country-flag-icons/3x2/flags.css'; import { uniqBy } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -125,6 +127,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { const [toggleWatchlist, setToggleWatchlist] = useState( !movie?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { @@ -155,6 +160,11 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl4k: data?.mediaInfo?.mediaUrl4k, @@ -374,6 +384,60 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { } }; + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: movie?.id, + mediaType: 'movie', + title: movie?.title, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: movie?.title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + closeBlacklistModal(); + }; + + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    { revalidate={() => revalidate()} show={showManager} /> +
    {
    - <> - {toggleWatchlist ? ( - - - - ) : ( + {showHideButton && + data?.mediaInfo?.status !== MediaStatus.PROCESSING && + data?.mediaInfo?.status !== MediaStatus.AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PENDING && + data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( )} - + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + + )} { - return (data?.parts ?? []).map((part) => part.id); + return (data?.parts ?? []) + .filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED) + .map((part) => part.id); }; const getAllRequestedParts = (): number[] => { @@ -248,6 +250,11 @@ const CollectionRequestModal = ({ { type: 'or' } ); + const blacklistVisibility = hasPermission( + [Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], + { type: 'or' } + ); + return ( - {data?.parts.map((part) => { - const partRequest = getPartRequest(part.id); - const partMedia = - part.mediaInfo && - part.mediaInfo[is4k ? 'status4k' : 'status'] !== - MediaStatus.UNKNOWN - ? part.mediaInfo - : undefined; + {data?.parts + .filter((part) => { + if (!blacklistVisibility) + return ( + part.mediaInfo?.status !== MediaStatus.BLACKLISTED + ); + return part; + }) + .map((part) => { + const partRequest = getPartRequest(part.id); + const partMedia = + part.mediaInfo && + part.mediaInfo[is4k ? 'status4k' : 'status'] !== + MediaStatus.UNKNOWN + ? part.mediaInfo + : undefined; - return ( - - - togglePart(part.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === 'Space') { - togglePart(part.id); - } - }} - className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ - !!partMedia || - partRequest || - (quota?.movie.limit && - currentlyRemaining <= 0 && - !isSelectedPart(part.id)) - ? 'opacity-50' - : '' + return ( + + - - - - -
    - togglePart(part.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + togglePart(part.id); + } }} - width={600} - height={900} - /> -
    -
    -
    - {part.releaseDate?.slice(0, 4)} + className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${ + (!!partMedia && + partMedia.status !== + MediaStatus.BLACKLISTED) || + partRequest || + (quota?.movie.limit && + currentlyRemaining <= 0 && + !isSelectedPart(part.id)) + ? 'opacity-50' + : '' + }`} + > + + + + + +
    +
    -
    - {part.title} +
    +
    + {part.releaseDate?.slice(0, 4)} +
    +
    + {part.title} +
    -
    - - - {!partMedia && !partRequest && ( - - {intl.formatMessage(globalMessages.notrequested)} - - )} - {!partMedia && - partRequest?.status === - MediaRequestStatus.PENDING && ( - - {intl.formatMessage(globalMessages.pending)} + + + {!partMedia && !partRequest && ( + + {intl.formatMessage( + globalMessages.notrequested + )} + + )} + {!partMedia && + partRequest?.status === + MediaRequestStatus.PENDING && ( + + {intl.formatMessage(globalMessages.pending)} + + )} + {((!partMedia && + partRequest?.status === + MediaRequestStatus.APPROVED) || + partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} + {partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( + + {intl.formatMessage(globalMessages.available)} + + )} + {partMedia?.status === MediaStatus.BLACKLISTED && ( + + {intl.formatMessage(globalMessages.blacklisted)} )} - {((!partMedia && - partRequest?.status === - MediaRequestStatus.APPROVED) || - partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.PROCESSING) && ( - - {intl.formatMessage(globalMessages.requested)} - - )} - {partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.AVAILABLE && ( - - {intl.formatMessage(globalMessages.available)} - - )} - - - ); - })} + + + ); + })}
    diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 1d280d289..0821c0175 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -360,6 +360,17 @@ const StatusBadge = ({ ); + case MediaStatus.BLACKLISTED: + return ( + + + {intl.formatMessage(is4k ? messages.status4k : messages.status, { + status: intl.formatMessage(globalMessages.blacklisted), + })} + + + ); + default: return null; } diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index b6c887968..2d10fdf1c 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -1,7 +1,9 @@ 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'; +import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; @@ -13,6 +15,8 @@ import { withProperties } from '@app/utils/typeHelpers'; import { Transition } from '@headlessui/react'; import { ArrowDownTrayIcon, + EyeIcon, + EyeSlashIcon, MinusCircleIcon, StarIcon, } from '@heroicons/react/24/outline'; @@ -20,7 +24,7 @@ import { MediaStatus } from '@server/constants/media'; import type { Watchlist } from '@server/entity/Watchlist'; import type { MediaType } from '@server/models/Search'; import Link from 'next/link'; -import { Fragment, useCallback, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import { mutate } from 'swr'; @@ -65,7 +69,7 @@ const TitleCard = ({ }: TitleCardProps) => { const isTouch = useIsTouch(); const intl = useIntl(); - const { hasPermission } = useUser(); + const { user, hasPermission } = useUser(); const [isUpdating, setIsUpdating] = useState(false); const [currentStatus, setCurrentStatus] = useState(status); const [showDetail, setShowDetail] = useState(false); @@ -74,6 +78,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) { @@ -94,6 +100,11 @@ const TitleCard = ({ [] ); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const onClickWatchlistBtn = async (): Promise => { setIsUpdating(true); try { @@ -166,6 +177,99 @@ const TitleCard = ({ } }; + const onClickHideItemBtn = async (): Promise => { + setIsUpdating(true); + const topNode = cardRef.current; + + if (topNode) { + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: id, + mediaType, + title, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.BLACKLISTED); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsUpdating(false); + closeBlacklistModal(); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + }; + + const onClickShowBlacklistBtn = async (): Promise => { + setIsUpdating(true); + const topNode = cardRef.current; + + if (topNode) { + const res = await fetch('/api/v1/blacklist/' + id, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.UNKNOWN); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsUpdating(false); + }; + const closeModal = useCallback(() => setShowRequestModal(false), []); const showRequestButton = hasPermission( @@ -178,10 +282,15 @@ const TitleCard = ({ { type: 'or' } ); + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    +
    - {showDetail && ( - <> + {showDetail && currentStatus !== MediaStatus.BLACKLISTED && ( +
    {toggleWatchlist ? ( )} - + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + + )} +
    )} + {showDetail && + showHideButton && + currentStatus == MediaStatus.BLACKLISTED && ( + + + + )} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && ( -
    - +
    +
    + +
    )}
    diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 634c72d05..cf788237b 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -4,6 +4,7 @@ import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; import Spinner from '@app/assets/spinner.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; +import BlacklistModal from '@app/components/BlacklistModal'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -38,6 +39,7 @@ import { ArrowRightCircleIcon, CogIcon, ExclamationTriangleIcon, + EyeSlashIcon, FilmIcon, PlayIcon, } from '@heroicons/react/24/outline'; @@ -61,7 +63,7 @@ import { countries } from 'country-flag-icons'; import 'country-flag-icons/3x2/flags.css'; import Link from 'next/link'; import { useRouter } from 'next/router'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; @@ -125,6 +127,9 @@ const TvDetails = ({ tv }: TvDetailsProps) => { const [toggleWatchlist, setToggleWatchlist] = useState( !tv?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); const { addToast } = useToasts(); const { @@ -155,6 +160,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => { setShowManager(router.query.manage == '1' ? true : false); }, [router.query.manage]); + const closeBlacklistModal = useCallback( + () => setShowBlacklistModal(false), + [] + ); + const { mediaUrl: plexUrl, mediaUrl4k: plexUrl4k } = useDeepLinks({ mediaUrl: data?.mediaInfo?.mediaUrl, mediaUrl4k: data?.mediaInfo?.mediaUrl4k, @@ -397,6 +407,60 @@ const TvDetails = ({ tv }: TvDetailsProps) => { } }; + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + const res = await fetch('/api/v1/blacklist', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tmdbId: tv?.id, + mediaType: 'tv', + title: tv?.name, + user: user?.id, + }), + }); + + if (res.status === 201) { + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: tv?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'info', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + closeBlacklistModal(); + }; + + const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], { + type: 'or', + }); + return (
    {
    )} + setShowIssueModal(false)} show={showIssueModal} @@ -528,40 +600,61 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
    - <> - {toggleWatchlist ? ( - - - - ) : ( + {showHideButton && + data?.mediaInfo?.status !== MediaStatus.PROCESSING && + data?.mediaInfo?.status !== MediaStatus.AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE && + data?.mediaInfo?.status !== MediaStatus.PENDING && + data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( )} - + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + + + + ) : ( + + + + )} + + )} {title} was successfully blacklisted.', + blacklistError: 'Something went wrong try again.', + blacklistDuplicateError: + '{title} has already been blacklisted.', + removeFromBlacklistSuccess: + '{title} was successfully removed from the Blacklist.', + addToBlacklist: 'Add to Blacklist', + removefromBlacklist: 'Remove from Blacklist', }); export default globalMessages; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index cf66b67e9..42e8e6f5b 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1,7 +1,18 @@ { + "component.BlacklistBlock.blacklistdate": "Blacklisted date", + "component.BlacklistBlock.blacklistedby": "Blacklisted By", + "component.BlacklistModal.blacklisting": "Blacklisting", "components.AirDateBadge.airedrelative": "Aired {relativeTime}", "components.AirDateBadge.airsrelative": "Airing {relativeTime}", "components.AppDataWarning.dockerVolumeMissingDescription": "The {appDataPath} volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", + "components.Blacklist.blacklistNotFoundError": "{title} is not blacklisted.", + "components.Blacklist.blacklistSettingsDescription": "Manage blacklisted media.", + "components.Blacklist.blacklistdate": "date", + "components.Blacklist.blacklistedby": "{date} by {user}", + "components.Blacklist.blacklistsettings": "Blacklist Settings", + "components.Blacklist.mediaName": "Name", + "components.Blacklist.mediaTmdbId": "tmdb Id", + "components.Blacklist.mediaType": "Type", "components.CollectionDetails.numberofmovies": "{count} Movies", "components.CollectionDetails.overview": "Overview", "components.CollectionDetails.requestcollection": "Request Collection", @@ -200,6 +211,7 @@ "components.LanguageSelector.originalLanguageDefault": "All Languages", "components.Layout.LanguagePicker.displaylanguage": "Display Language", "components.Layout.SearchInput.searchPlaceholder": "Search Movies & TV", + "components.Layout.Sidebar.blacklist": "Blacklist", "components.Layout.Sidebar.browsemovies": "Movies", "components.Layout.Sidebar.browsetv": "Series", "components.Layout.Sidebar.dashboard": "Discover", @@ -387,8 +399,12 @@ "components.PermissionEdit.autorequestMoviesDescription": "Grant permission to automatically submit requests for non-4K movies via Plex Watchlist.", "components.PermissionEdit.autorequestSeries": "Auto-Request Series", "components.PermissionEdit.autorequestSeriesDescription": "Grant permission to automatically submit requests for non-4K series via Plex Watchlist.", + "components.PermissionEdit.blacklistedItems": "Blacklist media.", + "components.PermissionEdit.blacklistedItemsDescription": "Grant permission to blacklist media.", "components.PermissionEdit.createissues": "Report Issues", "components.PermissionEdit.createissuesDescription": "Grant permission to report media issues.", + "components.PermissionEdit.manageblacklist": "Manage Blacklist", + "components.PermissionEdit.manageblacklistDescription": "Grant permission to manage blacklisted media.", "components.PermissionEdit.manageissues": "Manage Issues", "components.PermissionEdit.manageissuesDescription": "Grant permission to manage media issues.", "components.PermissionEdit.managerequests": "Manage Requests", @@ -407,6 +423,8 @@ "components.PermissionEdit.requestTvDescription": "Grant permission to submit requests for non-4K series.", "components.PermissionEdit.users": "Manage Users", "components.PermissionEdit.usersDescription": "Grant permission to manage users. Users with this permission cannot modify users with or grant the Admin privilege.", + "components.PermissionEdit.viewblacklistedItems": "View blacklisted media.", + "components.PermissionEdit.viewblacklistedItemsDescription": "Grant permission to view blacklisted media.", "components.PermissionEdit.viewissues": "View Issues", "components.PermissionEdit.viewissuesDescription": "Grant permission to view media issues reported by other users.", "components.PermissionEdit.viewrecent": "View Recently Added", @@ -1299,6 +1317,11 @@ "i18n.areyousure": "Are you sure?", "i18n.available": "Available", "i18n.back": "Back", + "i18n.blacklist": "Blacklist", + "i18n.blacklistDuplicateError": "{title} has already been blacklisted.", + "i18n.blacklistError": "Something went wrong try again.", + "i18n.blacklistSuccess": "{title} was successfully blacklisted.", + "i18n.blacklisted": "Blacklisted", "i18n.cancel": "Cancel", "i18n.canceling": "Canceling…", "i18n.close": "Close", @@ -1324,6 +1347,8 @@ "i18n.pending": "Pending", "i18n.previous": "Previous", "i18n.processing": "Processing", + "i18n.removeFromBlacklistSuccess": "{title} was successfully removed from the Blacklist.", + "i18n.removefromBlacklist": "Remove from Blacklist", "i18n.request": "Request", "i18n.request4k": "Request in 4K", "i18n.requested": "Requested", diff --git a/src/pages/blacklist/index.tsx b/src/pages/blacklist/index.tsx new file mode 100644 index 000000000..e7e3903b0 --- /dev/null +++ b/src/pages/blacklist/index.tsx @@ -0,0 +1,13 @@ +import Blacklist from '@app/components/Blacklist'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@server/lib/permissions'; +import type { NextPage } from 'next'; + +const BlacklistPage: NextPage = () => { + useRouteGuard([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { + type: 'or', + }); + return ; +}; + +export default BlacklistPage;