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<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); + } + } +} 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<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"`); + } +} 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<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 }); } 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: '<strong>{title}</strong> is not blacklisted.', +}); + +const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { + return (movie as MovieDetails).title !== undefined; +}; + +const Blacklist = () => { + const [currentPageSize, setCurrentPageSize] = useState<number>(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<BlacklistResultsResponse>( + `/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 <Error statusCode={500} />; + } + + const searchItem = (e: ChangeEvent<HTMLInputElement>) => { + // 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 ( + <> + <PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} /> + <Header>{intl.formatMessage(globalMessages.blacklist)}</Header> + + <div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end"> + <div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0"> + <span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100"> + <MagnifyingGlassIcon className="h-6 w-6" /> + </span> + <input + type="text" + className="rounded-r-only" + value={searchFilter} + onChange={(e) => searchItem(e)} + /> + </div> + </div> + + {!data ? ( + <LoadingSpinner /> + ) : data.results.length === 0 ? ( + <div className="flex w-full flex-col items-center justify-center py-24 text-white"> + <span className="text-2xl text-gray-400"> + {intl.formatMessage(globalMessages.noresults)} + </span> + </div> + ) : ( + data.results.map((item: BlacklistItem) => { + return ( + <div className="py-2" key={`request-list-${item.tmdbId}`}> + <BlacklistedItem item={item} revalidateList={revalidate} /> + </div> + ); + }) + )} + + <div className="actions"> + <nav + className="mb-3 flex flex-col items-center space-y-3 sm:flex-row sm:space-y-0" + aria-label="Pagination" + > + <div className="hidden lg:flex lg:flex-1"> + <p className="text-sm"> + {data && + (data?.results.length ?? 0) > 0 && + intl.formatMessage(globalMessages.showingresults, { + from: pageIndex * currentPageSize + 1, + to: + data.results.length < currentPageSize + ? pageIndex * currentPageSize + data.results.length + : (pageIndex + 1) * currentPageSize, + total: data.pageInfo.results, + strong: (msg: React.ReactNode) => ( + <span className="font-medium">{msg}</span> + ), + })} + </p> + </div> + <div className="flex justify-center sm:flex-1 sm:justify-start lg:justify-center"> + <span className="-mt-3 items-center truncate text-sm sm:mt-0"> + {intl.formatMessage(globalMessages.resultsperpage, { + pageSize: ( + <select + id="pageSize" + name="pageSize" + onChange={(e) => { + setCurrentPageSize(Number(e.target.value)); + router + .push({ + pathname: router.pathname, + query: router.query.userId + ? { userId: router.query.userId } + : {}, + }) + .then(() => window.scrollTo(0, 0)); + }} + value={currentPageSize} + className="short inline" + > + <option value="5">5</option> + <option value="10">10</option> + <option value="25">25</option> + <option value="50">50</option> + <option value="100">100</option> + </select> + ), + })} + </span> + </div> + <div className="flex flex-auto justify-center space-x-2 sm:flex-1 sm:justify-end"> + <Button + disabled={!hasPrevPage} + onClick={() => updateQueryParams('page', (page - 1).toString())} + > + <ChevronLeftIcon /> + <span>{intl.formatMessage(globalMessages.previous)}</span> + </Button> + <Button + disabled={!hasNextPage} + onClick={() => updateQueryParams('page', (page + 1).toString())} + > + <span>{intl.formatMessage(globalMessages.next)}</span> + <ChevronRightIcon /> + </Button> + </div> + </nav> + </div> + </> + ); +}; + +export default Blacklist; + +interface BlacklistedItemProps { + item: BlacklistItem; + revalidateList: () => void; +} + +const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { + const [isUpdating, setIsUpdating] = useState<boolean>(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<MovieDetails | TvDetails>( + inView ? url : null + ); + + if (!title && !error) { + return ( + <div + className="h-64 w-full animate-pulse rounded-xl bg-gray-800 xl:h-28" + ref={ref} + /> + ); + } + + const removeFromBlacklist = async (tmdbId: number, title?: string) => { + setIsUpdating(true); + + const res = await fetch('/api/v1/blacklist/' + tmdbId, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + <span> + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + revalidateList(); + setIsUpdating(false); + }; + + return ( + <div className="relative flex w-full flex-col justify-between overflow-hidden rounded-xl bg-gray-800 py-4 text-gray-400 shadow-md ring-1 ring-gray-700 xl:h-28 xl:flex-row"> + {title && title.backdropPath && ( + <div className="absolute inset-0 z-0 w-full bg-cover bg-center xl:w-2/3"> + <CachedImage + src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${title.backdropPath}`} + alt="" + style={{ width: '100%', height: '100%', objectFit: 'cover' }} + fill + /> + <div + className="absolute inset-0" + style={{ + backgroundImage: + 'linear-gradient(90deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 1) 100%)', + }} + /> + </div> + )} + <div className="relative flex w-full flex-col justify-between overflow-hidden sm:flex-row"> + <div className="relative z-10 flex w-full items-center overflow-hidden pl-4 pr-4 sm:pr-0 xl:w-7/12 2xl:w-2/3"> + <Link + href={ + item.mediaType === 'movie' + ? `/movie/${item.tmdbId}` + : `/tv/${item.tmdbId}` + } + className="relative h-auto w-12 flex-shrink-0 scale-100 transform-gpu overflow-hidden rounded-md transition duration-300 hover:scale-105" + > + <CachedImage + src={ + title?.posterPath + ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${title.posterPath}` + : '/images/overseerr_poster_not_found.png' + } + alt="" + sizes="100vw" + style={{ width: '100%', height: 'auto', objectFit: 'cover' }} + width={600} + height={900} + /> + </Link> + <div className="flex flex-col justify-center overflow-hidden pl-2 xl:pl-4"> + <div className="pt-0.5 text-xs font-medium text-white sm:pt-1"> + {title && + (isMovie(title) + ? title.releaseDate + : title.firstAirDate + )?.slice(0, 4)} + </div> + <Link + href={ + item.mediaType === 'movie' + ? `/movie/${item.tmdbId}` + : `/tv/${item.tmdbId}` + } + > + <span className="mr-2 min-w-0 truncate text-lg font-bold text-white hover:underline xl:text-xl"> + {title && (isMovie(title) ? title.title : title.name)} + </span> + </Link> + </div> + </div> + + <div className="z-10 mt-4 ml-4 flex w-full flex-col justify-center overflow-hidden pr-4 text-sm sm:ml-2 sm:mt-0 xl:flex-1 xl:pr-0"> + <div className="card-field"> + <span className="card-field-name">Status</span> + <Badge badgeType="danger"> + {intl.formatMessage(globalMessages.blacklisted)} + </Badge> + </div> + + {item.createdAt && ( + <div className="card-field"> + <span className="card-field-name"> + {intl.formatMessage(globalMessages.blacklisted)} + </span> + <span className="flex truncate text-sm text-gray-300"> + {intl.formatMessage(messages.blacklistedby, { + date: ( + <FormattedRelativeTime + value={Math.floor( + (new Date(item.createdAt).getTime() - Date.now()) / 1000 + )} + updateIntervalInSeconds={1} + numeric="auto" + /> + ), + user: ( + <Link href={`/users/${item.user.id}`}> + <span className="group flex items-center truncate"> + <CachedImage + src={item.user.avatar} + alt="" + className="avatar-sm ml-1.5" + width={20} + height={20} + style={{ objectFit: 'cover' }} + /> + <span className="ml-1 truncate text-sm font-semibold group-hover:text-white group-hover:underline"> + {item.user.displayName} + </span> + </span> + </Link> + ), + })} + </span> + </div> + )} + <div className="card-field"> + {item.mediaType === 'movie' ? ( + <div className="pointer-events-none z-40 self-start rounded-full border border-blue-500 bg-blue-600 bg-opacity-80 shadow-md"> + <div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5"> + {intl.formatMessage(globalMessages.movie)} + </div> + </div> + ) : ( + <div className="pointer-events-none z-40 self-start rounded-full border border-purple-600 bg-purple-600 bg-opacity-80 shadow-md"> + <div className="flex h-4 items-center px-2 py-2 text-center text-xs font-medium uppercase tracking-wider text-white sm:h-5"> + {intl.formatMessage(globalMessages.tvshow)} + </div> + </div> + )} + </div> + </div> + </div> + <div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0"> + {hasPermission(Permission.MANAGE_BLACKLIST) && ( + <ConfirmButton + onClick={() => + 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' : '' + }`} + > + <TrashIcon /> + <span> + {intl.formatMessage(globalMessages.removefromBlacklist)} + </span> + </ConfirmButton> + )} + </div> + </div> + ); +}; 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( + <span> + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { appearance: 'success', autoDismiss: true } + ); + } else { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + onUpdate && onUpdate(); + onDelete && onDelete(); + + setIsUpdating(false); + }; + + return ( + <div className="px-4 py-3 text-gray-300"> + <div className="flex items-center justify-between"> + <div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5"> + <div className="white mb-1 flex flex-nowrap"> + <Tooltip content={intl.formatMessage(messages.blacklistedby)}> + <UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" /> + </Tooltip> + <span className="w-40 truncate md:w-auto"> + <Link + href={ + blacklistItem.user.id === user?.id + ? '/profile' + : `/users/${blacklistItem.user.id}` + } + > + <span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline"> + {blacklistItem.user.displayName} + </span> + </Link> + </span> + </div> + </div> + <div className="ml-2 flex flex-shrink-0 flex-wrap"> + <Tooltip + content={intl.formatMessage(globalMessages.removefromBlacklist)} + > + <Button + buttonType="danger" + onClick={() => + removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title) + } + disabled={isUpdating} + > + <TrashIcon className="icon-sm" /> + </Button> + </Tooltip> + </div> + </div> + <div className="mt-2 sm:flex sm:justify-between"> + <div className="sm:flex"> + <div className="mr-6 flex items-center text-sm leading-5"> + <Badge badgeType="danger"> + {intl.formatMessage(globalMessages.blacklisted)} + </Badge> + </div> + </div> + <div className="mt-2 flex items-center text-sm leading-5 sm:mt-0"> + <Tooltip content={intl.formatMessage(messages.blacklistdate)}> + <CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" /> + </Tooltip> + <span> + {intl.formatDate(blacklistItem.createdAt, { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + </span> + </div> + </div> + </div> + ); +}; + +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<TvDetails | MovieDetails>( + `/api/v1/${type}/${tmdbId}` + ); + + return ( + <Transition + as="div" + enter="transition-opacity duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="transition-opacity duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0" + show={show} + > + <Modal + loading={!data && !error} + backgroundClickable + title={`${intl.formatMessage(globalMessages.blacklist)} ${ + isMovie(data) + ? intl.formatMessage(globalMessages.movie) + : intl.formatMessage(globalMessages.tvshow) + }`} + subTitle={`${isMovie(data) ? data.title : data?.name}`} + onCancel={onCancel} + onOk={onComplete} + okText={ + isUpdating + ? intl.formatMessage(messages.blacklisting) + : intl.formatMessage(globalMessages.blacklist) + } + okButtonType="danger" + okDisabled={isUpdating} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + /> + </Transition> + ); +}; + +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 ( <div className="media-page" @@ -335,20 +340,26 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { sliderKey="collection-movies" isLoading={false} isEmpty={data.parts.length === 0} - items={data.parts.map((title) => ( - <TitleCard - key={`collection-movie-${title.id}`} - id={title.id} - isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={title.posterPath} - status={title.mediaInfo?.status} - summary={title.overview} - title={title.title} - userScore={title.voteAverage} - year={title.releaseDate} - mediaType={title.mediaType} - /> - ))} + items={data.parts + .filter((title) => { + if (!blacklistVisibility) + return title.mediaInfo?.status !== MediaStatus.BLACKLISTED; + return title; + }) + .map((title) => ( + <TitleCard + key={`collection-movie-${title.id}`} + id={title.id} + isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} + image={title.posterPath} + status={title.mediaInfo?.status} + summary={title.overview} + title={title.title} + userScore={title.voteAverage} + year={title.releaseDate} + mediaType={title.mediaType} + /> + ))} /> <div className="extra-bottom-space relative" /> </div> 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 = ({ </li> ); })} - {items?.map((title, index) => { - let titleCard: React.ReactNode; - - switch (title.mediaType) { - case 'movie': - titleCard = ( - <TitleCard - key={title.id} - id={title.id} - isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={title.posterPath} - status={title.mediaInfo?.status} - summary={title.overview} - title={title.title} - userScore={title.voteAverage} - year={title.releaseDate} - mediaType={title.mediaType} - inProgress={ - (title.mediaInfo?.downloadStatus ?? []).length > 0 - } - canExpand - /> - ); - break; - case 'tv': - titleCard = ( - <TitleCard - key={title.id} - id={title.id} - isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={title.posterPath} - status={title.mediaInfo?.status} - summary={title.overview} - title={title.name} - userScore={title.voteAverage} - year={title.firstAirDate} - mediaType={title.mediaType} - inProgress={ - (title.mediaInfo?.downloadStatus ?? []).length > 0 - } - canExpand - /> + {items + ?.filter((title) => { + if (!blacklistVisibility) + return ( + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED ); - break; - case 'collection': - titleCard = ( - <TitleCard - id={title.id} - image={title.posterPath} - summary={title.overview} - title={title.title} - mediaType={title.mediaType} - canExpand - /> - ); - break; - case 'person': - titleCard = ( - <PersonCard - personId={title.id} - name={title.name} - profilePath={title.profilePath} - canExpand - /> - ); - break; - } + return title; + }) + .map((title, index) => { + let titleCard: React.ReactNode; - return <li key={`${title.id}-${index}`}>{titleCard}</li>; - })} + switch (title.mediaType) { + case 'movie': + titleCard = ( + <TitleCard + key={title.id} + id={title.id} + isAddedToWatchlist={ + title.mediaInfo?.watchlists?.length ?? 0 + } + image={title.posterPath} + status={title.mediaInfo?.status} + summary={title.overview} + title={title.title} + userScore={title.voteAverage} + year={title.releaseDate} + mediaType={title.mediaType} + inProgress={ + (title.mediaInfo?.downloadStatus ?? []).length > 0 + } + canExpand + /> + ); + break; + case 'tv': + titleCard = ( + <TitleCard + key={title.id} + id={title.id} + isAddedToWatchlist={ + title.mediaInfo?.watchlists?.length ?? 0 + } + image={title.posterPath} + status={title.mediaInfo?.status} + summary={title.overview} + title={title.name} + userScore={title.voteAverage} + year={title.firstAirDate} + mediaType={title.mediaType} + inProgress={ + (title.mediaInfo?.downloadStatus ?? []).length > 0 + } + canExpand + /> + ); + break; + case 'collection': + titleCard = ( + <TitleCard + id={title.id} + image={title.posterPath} + summary={title.overview} + title={title.title} + mediaType={title.mediaType} + canExpand + /> + ); + break; + case 'person': + titleCard = ( + <PersonCard + personId={title.id} + name={title.name} + profilePath={title.profilePath} + canExpand + /> + ); + break; + } + + return <li key={`${title.id}-${index}`}>{titleCard}</li>; + })} {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 = <BellIcon />; break; + case MediaStatus.BLACKLISTED: + badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white'); + indicatorIcon = <EyeSlashIcon />; + 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: <ClockIcon className="mr-3 h-6 w-6" />, activeRegExp: /^\/requests/, }, + { + href: '/blacklist', + messagesKey: 'blacklist', + svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />, + 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 = ({ </div> </div> )} + {data.mediaInfo?.status === MediaStatus.BLACKLISTED && ( + <div> + <h3 className="mb-2 text-xl font-bold"> + {intl.formatMessage(globalMessages.blacklist)} + </h3> + <div className="overflow-hidden rounded-md border border-gray-700 shadow"> + <BlacklistBlock + blacklistItem={data.mediaInfo.blacklist} + onUpdate={() => revalidate()} + onDelete={() => onClose()} + /> + </div> + </div> + )} {hasPermission(Permission.ADMIN) && (data.mediaInfo?.serviceUrl || data.mediaInfo?.tautulliUrl || @@ -603,32 +618,17 @@ const ManageSlideOver = ({ </div> </div> )} - {hasPermission(Permission.ADMIN) && data?.mediaInfo && ( - <div> - <h3 className="mb-2 text-xl font-bold"> - {intl.formatMessage(messages.manageModalAdvanced)} - </h3> - <div className="space-y-2"> - {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( - <Button - onClick={() => markAvailable()} - className="w-full" - buttonType="success" - > - <CheckCircleIcon /> - <span> - {intl.formatMessage( - mediaType === 'movie' - ? messages.markavailable - : messages.markallseasonsavailable - )} - </span> - </Button> - )} - {data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && - settings.currentSettings.series4kEnabled && ( + {hasPermission(Permission.ADMIN) && + data?.mediaInfo && + data.mediaInfo.status !== MediaStatus.BLACKLISTED && ( + <div> + <h3 className="mb-2 text-xl font-bold"> + {intl.formatMessage(messages.manageModalAdvanced)} + </h3> + <div className="space-y-2"> + {data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( <Button - onClick={() => markAvailable(true)} + onClick={() => markAvailable()} className="w-full" buttonType="success" > @@ -636,42 +636,59 @@ const ManageSlideOver = ({ <span> {intl.formatMessage( mediaType === 'movie' - ? messages.mark4kavailable - : messages.markallseasons4kavailable + ? messages.markavailable + : messages.markallseasonsavailable )} </span> </Button> )} - <div> - <ConfirmButton - onClick={() => deleteMedia()} - confirmText={intl.formatMessage(globalMessages.areyousure)} - className="w-full" - > - <DocumentMinusIcon /> - <span> - {intl.formatMessage(messages.manageModalClearMedia)} - </span> - </ConfirmButton> - <div className="mt-2 text-xs text-gray-400"> - {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 && ( + <Button + onClick={() => markAvailable(true)} + className="w-full" + buttonType="success" + > + <CheckCircleIcon /> + <span> + {intl.formatMessage( + mediaType === 'movie' + ? messages.mark4kavailable + : messages.markallseasons4kavailable + )} + </span> + </Button> + )} + <div> + <ConfirmButton + onClick={() => deleteMedia()} + confirmText={intl.formatMessage(globalMessages.areyousure)} + className="w-full" + > + <DocumentMinusIcon /> + <span> + {intl.formatMessage(messages.manageModalClearMedia)} + </span> + </ConfirmButton> + <div className="mt-2 text-xs text-gray-400"> + {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', + })} + </div> </div> </div> </div> - </div> - )} + )} </div> </SlideOver> ); 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<MixedResult>( (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 ( - <TitleCard - key={title.id} - id={title.id} - isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={title.posterPath} - status={title.mediaInfo?.status} - summary={title.overview} - title={title.title} - userScore={title.voteAverage} - year={title.releaseDate} - mediaType={title.mediaType} - inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} - /> - ); - case 'tv': - return ( - <TitleCard - key={title.id} - id={title.id} - isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} - image={title.posterPath} - status={title.mediaInfo?.status} - summary={title.overview} - title={title.name} - userScore={title.voteAverage} - year={title.firstAirDate} - mediaType={title.mediaType} - inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 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 ( - <PersonCard - personId={title.id} - name={title.name} - profilePath={title.profilePath} - /> + (title as TvResult | MovieResult).mediaInfo?.status !== + MediaStatus.BLACKLISTED ); - } - }); + return title; + }) + .map((title) => { + switch (title.mediaType) { + case 'movie': + return ( + <TitleCard + key={title.id} + id={title.id} + isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} + image={title.posterPath} + status={title.mediaInfo?.status} + summary={title.overview} + title={title.title} + userScore={title.voteAverage} + year={title.releaseDate} + mediaType={title.mediaType} + inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + /> + ); + case 'tv': + return ( + <TitleCard + key={title.id} + id={title.id} + isAddedToWatchlist={title.mediaInfo?.watchlists?.length ?? 0} + image={title.posterPath} + status={title.mediaInfo?.status} + summary={title.overview} + title={title.name} + userScore={title.voteAverage} + year={title.firstAirDate} + mediaType={title.mediaType} + inProgress={(title.mediaInfo?.downloadStatus ?? []).length > 0} + /> + ); + case 'person': + return ( + <PersonCard + personId={title.id} + name={title.name} + profilePath={title.profilePath} + /> + ); + } + }); 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<boolean>( !movie?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState<boolean>(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<void> => { + 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( + <span> + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: movie?.title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + <span> + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: movie?.title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { 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 ( <div className="media-page" @@ -419,6 +483,14 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { revalidate={() => revalidate()} show={showManager} /> + <BlacklistModal + tmdbId={data.id} + type="movie" + show={showBlacklistModal} + onCancel={closeBlacklistModal} + onComplete={onClickHideItemBtn} + isUpdating={isBlacklistUpdating} + /> <div className="media-header"> <div className="media-poster"> <CachedImage @@ -495,40 +567,61 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { </span> </div> <div className="media-actions"> - <> - {toggleWatchlist ? ( - <Tooltip content={intl.formatMessage(messages.addtowatchlist)}> - <Button - buttonType={'ghost'} - className="z-40 mr-2" - buttonSize={'md'} - onClick={onClickWatchlistBtn} - > - {isUpdating ? ( - <Spinner className="h-3" /> - ) : ( - <StarIcon className={'h-3 text-amber-300'} /> - )} - </Button> - </Tooltip> - ) : ( + {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 && ( <Tooltip - content={intl.formatMessage(messages.removefromwatchlist)} + content={intl.formatMessage(globalMessages.addToBlacklist)} > <Button + buttonType={'ghost'} className="z-40 mr-2" buttonSize={'md'} - onClick={onClickDeleteWatchlistBtn} + onClick={() => setShowBlacklistModal(true)} > - {isUpdating ? ( - <Spinner className="h-3" /> - ) : ( - <MinusCircleIcon className={'h-3'} /> - )} + <EyeSlashIcon className={'h-3'} /> </Button> </Tooltip> )} - </> + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + <Tooltip content={intl.formatMessage(messages.addtowatchlist)}> + <Button + buttonType={'ghost'} + className="z-40 mr-2" + buttonSize={'md'} + onClick={onClickWatchlistBtn} + > + {isUpdating ? ( + <Spinner className="h-3" /> + ) : ( + <StarIcon className={'h-3 text-amber-300'} /> + )} + </Button> + </Tooltip> + ) : ( + <Tooltip + content={intl.formatMessage(messages.removefromwatchlist)} + > + <Button + className="z-40 mr-2" + buttonSize={'md'} + onClick={onClickDeleteWatchlistBtn} + > + {isUpdating ? ( + <Spinner className="h-3" /> + ) : ( + <MinusCircleIcon className={'h-3'} /> + )} + </Button> + </Tooltip> + )} + </> + )} <PlayButton links={mediaLinks} /> <RequestButton mediaType="movie" diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx index a220335bd..5a861de8a 100644 --- a/src/components/PermissionEdit/index.tsx +++ b/src/components/PermissionEdit/index.tsx @@ -78,6 +78,13 @@ export const messages = defineMessages('components.PermissionEdit', { viewwatchlists: 'View {mediaServerName} Watchlists', viewwatchlistsDescription: "Grant permission to view other users' {mediaServerName} Watchlists.", + manageblacklist: 'Manage Blacklist', + manageblacklistDescription: 'Grant permission to manage blacklisted media.', + blacklistedItems: 'Blacklist media.', + blacklistedItemsDescription: 'Grant permission to blacklist media.', + viewblacklistedItems: 'View blacklisted media.', + viewblacklistedItemsDescription: + 'Grant permission to view blacklisted media.', }); interface PermissionEditProps { @@ -332,6 +339,22 @@ export const PermissionEdit = ({ }, ], }, + { + id: 'manageblacklist', + name: intl.formatMessage(messages.manageblacklist), + description: intl.formatMessage(messages.manageblacklistDescription), + permission: Permission.MANAGE_BLACKLIST, + children: [ + { + id: 'viewblacklisteditems', + name: intl.formatMessage(messages.viewblacklistedItems), + description: intl.formatMessage( + messages.viewblacklistedItemsDescription + ), + permission: Permission.VIEW_BLACKLIST, + }, + ], + }, ]; return ( diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index cf27e55da..cbe04fe3d 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -300,6 +300,7 @@ const RequestButton = ({ }) && media && media.status !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.BLACKLISTED && !isShowComplete ) { buttons.push({ @@ -345,6 +346,7 @@ const RequestButton = ({ }) && media && media.status4k !== MediaStatus.AVAILABLE && + media.status !== MediaStatus.BLACKLISTED && !is4kShowComplete && settings.currentSettings.series4kEnabled ) { diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index 2cf09c223..b646f7b1d 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -66,7 +66,9 @@ const CollectionRequestModal = ({ (quota?.movie.remaining ?? 0) - selectedParts.length; const getAllParts = (): number[] => { - 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 ( <Modal loading={(!data && !error) || !quota} @@ -344,122 +351,156 @@ const CollectionRequestModal = ({ </tr> </thead> <tbody className="divide-y divide-gray-700"> - {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 ( - <tr key={`part-${part.id}`}> - <td className="whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100"> - <span - role="checkbox" - tabIndex={0} - aria-checked={ - !!partMedia || isSelectedPart(part.id) - } - onClick={() => 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 ( + <tr key={`part-${part.id}`}> + <td + className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${ + partMedia?.status === MediaStatus.BLACKLISTED && + 'pointer-events-none opacity-50' }`} > <span - aria-hidden="true" - className={`${ - !!partMedia || - partRequest || - isSelectedPart(part.id) - ? 'bg-indigo-500' - : 'bg-gray-700' - } absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`} - ></span> - <span - aria-hidden="true" - className={`${ - !!partMedia || - partRequest || + role="checkbox" + tabIndex={0} + aria-checked={ + (!!partMedia && + partMedia.status !== + MediaStatus.BLACKLISTED) || isSelectedPart(part.id) - ? 'translate-x-5' - : 'translate-x-0' - } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} - ></span> - </span> - </td> - <td className="flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6"> - <div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md"> - <CachedImage - src={ - part.posterPath - ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}` - : '/images/overseerr_poster_not_found.png' } - alt="" - sizes="100vw" - style={{ - width: '100%', - height: 'auto', - objectFit: 'cover', + onClick={() => togglePart(part.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === 'Space') { + togglePart(part.id); + } }} - width={600} - height={900} - /> - </div> - <div className="flex flex-col justify-center pl-2"> - <div className="text-xs font-medium"> - {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' + : '' + }`} + > + <span + aria-hidden="true" + className={`${ + (!!partMedia && + partMedia.status !== + MediaStatus.BLACKLISTED) || + partRequest || + isSelectedPart(part.id) + ? 'bg-indigo-500' + : 'bg-gray-700' + } absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out`} + ></span> + <span + aria-hidden="true" + className={`${ + (!!partMedia && + partMedia.status !== + MediaStatus.BLACKLISTED) || + partRequest || + isSelectedPart(part.id) + ? 'translate-x-5' + : 'translate-x-0' + } absolute left-0 inline-block h-5 w-5 rounded-full border border-gray-200 bg-white shadow transition-transform duration-200 ease-in-out group-focus:border-blue-300 group-focus:ring`} + ></span> + </span> + </td> + <td + className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${ + partMedia?.status === MediaStatus.BLACKLISTED && + 'pointer-events-none opacity-50' + }`} + > + <div className="relative h-auto w-10 flex-shrink-0 overflow-hidden rounded-md"> + <CachedImage + src={ + part.posterPath + ? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${part.posterPath}` + : '/images/overseerr_poster_not_found.png' + } + alt="" + sizes="100vw" + style={{ + width: '100%', + height: 'auto', + objectFit: 'cover', + }} + width={600} + height={900} + /> </div> - <div className="text-base font-bold"> - {part.title} + <div className="flex flex-col justify-center pl-2"> + <div className="text-xs font-medium"> + {part.releaseDate?.slice(0, 4)} + </div> + <div className="text-base font-bold"> + {part.title} + </div> </div> - </div> - </td> - <td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6"> - {!partMedia && !partRequest && ( - <Badge> - {intl.formatMessage(globalMessages.notrequested)} - </Badge> - )} - {!partMedia && - partRequest?.status === - MediaRequestStatus.PENDING && ( - <Badge badgeType="warning"> - {intl.formatMessage(globalMessages.pending)} + </td> + <td className="whitespace-nowrap py-4 pr-2 text-sm leading-5 text-gray-200 md:px-6"> + {!partMedia && !partRequest && ( + <Badge> + {intl.formatMessage( + globalMessages.notrequested + )} + </Badge> + )} + {!partMedia && + partRequest?.status === + MediaRequestStatus.PENDING && ( + <Badge badgeType="warning"> + {intl.formatMessage(globalMessages.pending)} + </Badge> + )} + {((!partMedia && + partRequest?.status === + MediaRequestStatus.APPROVED) || + partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + <Badge badgeType="primary"> + {intl.formatMessage(globalMessages.requested)} + </Badge> + )} + {partMedia?.[is4k ? 'status4k' : 'status'] === + MediaStatus.AVAILABLE && ( + <Badge badgeType="success"> + {intl.formatMessage(globalMessages.available)} + </Badge> + )} + {partMedia?.status === MediaStatus.BLACKLISTED && ( + <Badge badgeType="danger"> + {intl.formatMessage(globalMessages.blacklisted)} </Badge> )} - {((!partMedia && - partRequest?.status === - MediaRequestStatus.APPROVED) || - partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.PROCESSING) && ( - <Badge badgeType="primary"> - {intl.formatMessage(globalMessages.requested)} - </Badge> - )} - {partMedia?.[is4k ? 'status4k' : 'status'] === - MediaStatus.AVAILABLE && ( - <Badge badgeType="success"> - {intl.formatMessage(globalMessages.available)} - </Badge> - )} - </td> - </tr> - ); - })} + </td> + </tr> + ); + })} </tbody> </table> </div> 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 = ({ </Tooltip> ); + case MediaStatus.BLACKLISTED: + return ( + <Tooltip content={mediaLinkDescription}> + <Badge badgeType="danger" href={mediaLink}> + {intl.formatMessage(is4k ? messages.status4k : messages.status, { + status: intl.formatMessage(globalMessages.blacklisted), + })} + </Badge> + </Tooltip> + ); + 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<boolean>( !isAddedToWatchlist ); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); + const cardRef = useRef<HTMLDivElement>(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<void> => { setIsUpdating(true); try { @@ -166,6 +177,99 @@ const TitleCard = ({ } }; + const onClickHideItemBtn = async (): Promise<void> => { + 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( + <span> + {intl.formatMessage(globalMessages.blacklistSuccess, { + title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.BLACKLISTED); + } else if (res.status === 412) { + addToast( + <span> + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { 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<void> => { + setIsUpdating(true); + const topNode = cardRef.current; + + if (topNode) { + const res = await fetch('/api/v1/blacklist/' + id, { + method: 'DELETE', + }); + + if (res.status === 204) { + addToast( + <span> + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { 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 ( <div className={canExpand ? 'w-full' : 'w-36 sm:w-36 md:w-44'} data-testid="title-card" + ref={cardRef} > <RequestModal tmdbId={id} @@ -197,6 +306,20 @@ const TitleCard = ({ onUpdating={requestUpdating} onCancel={closeModal} /> + <BlacklistModal + tmdbId={id} + type={ + mediaType === 'movie' + ? 'movie' + : mediaType === 'collection' + ? 'collection' + : 'tv' + } + show={showBlacklistModal} + onCancel={closeBlacklistModal} + onComplete={onClickHideItemBtn} + isUpdating={isUpdating} + /> <div className={`relative transform-gpu cursor-default overflow-hidden rounded-xl bg-gray-800 bg-cover outline-none ring-1 transition duration-300 ${ showDetail @@ -235,7 +358,7 @@ const TitleCard = ({ /> <div className="absolute left-0 right-0 flex items-center justify-between p-2"> <div - className={`pointer-events-none z-40 rounded-full border bg-opacity-80 shadow-md ${ + className={`pointer-events-none z-40 self-start rounded-full border bg-opacity-80 shadow-md ${ mediaType === 'movie' || mediaType === 'collection' ? 'border-blue-500 bg-blue-600' : 'border-purple-600 bg-purple-600' @@ -249,8 +372,8 @@ const TitleCard = ({ : intl.formatMessage(globalMessages.tvshow)} </div> </div> - {showDetail && ( - <> + {showDetail && currentStatus !== MediaStatus.BLACKLISTED && ( + <div className="flex flex-col gap-1"> {toggleWatchlist ? ( <Button buttonType={'ghost'} @@ -269,15 +392,49 @@ const TitleCard = ({ <MinusCircleIcon className={'h-3'} /> </Button> )} - </> + {showHideButton && + currentStatus !== MediaStatus.PROCESSING && + currentStatus !== MediaStatus.AVAILABLE && + currentStatus !== MediaStatus.PARTIALLY_AVAILABLE && + currentStatus !== MediaStatus.PENDING && ( + <Button + buttonType={'ghost'} + className="z-40" + buttonSize={'sm'} + onClick={() => setShowBlacklistModal(true)} + > + <EyeSlashIcon className={'h-3'} /> + </Button> + )} + </div> )} + {showDetail && + showHideButton && + currentStatus == MediaStatus.BLACKLISTED && ( + <Tooltip + content={intl.formatMessage( + globalMessages.removefromBlacklist + )} + > + <Button + buttonType={'ghost'} + className="z-40" + buttonSize={'sm'} + onClick={() => onClickShowBlacklistBtn()} + > + <EyeIcon className={'h-3'} /> + </Button> + </Tooltip> + )} {currentStatus && currentStatus !== MediaStatus.UNKNOWN && ( - <div className="pointer-events-none z-40 flex items-center"> - <StatusBadgeMini - status={currentStatus} - inProgress={inProgress} - shrink - /> + <div className="flex flex-col items-center gap-1"> + <div className="pointer-events-none z-40 flex"> + <StatusBadgeMini + status={currentStatus} + inProgress={inProgress} + shrink + /> + </div> </div> )} </div> 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<boolean>( !tv?.onUserWatchlist ); + const [isBlacklistUpdating, setIsBlacklistUpdating] = + useState<boolean>(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<void> => { + 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( + <span> + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: tv?.name, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } else if (res.status === 412) { + addToast( + <span> + {intl.formatMessage(globalMessages.blacklistDuplicateError, { + title: tv?.name, + strong: (msg: React.ReactNode) => <strong>{msg}</strong>, + })} + </span>, + { 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 ( <div className="media-page" @@ -423,6 +487,14 @@ const TvDetails = ({ tv }: TvDetailsProps) => { </div> )} <PageTitle title={data.name} /> + <BlacklistModal + tmdbId={data.id} + type="tv" + show={showBlacklistModal} + onCancel={closeBlacklistModal} + onComplete={onClickHideItemBtn} + isUpdating={isBlacklistUpdating} + /> <IssueModal onCancel={() => setShowIssueModal(false)} show={showIssueModal} @@ -528,40 +600,61 @@ const TvDetails = ({ tv }: TvDetailsProps) => { </span> </div> <div className="media-actions"> - <> - {toggleWatchlist ? ( - <Tooltip content={intl.formatMessage(messages.addtowatchlist)}> - <Button - buttonType={'ghost'} - className="z-40 mr-2" - buttonSize={'md'} - onClick={onClickWatchlistBtn} - > - {isUpdating ? ( - <Spinner className="h-3" /> - ) : ( - <StarIcon className={'h-3 text-amber-300'} /> - )} - </Button> - </Tooltip> - ) : ( + {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 && ( <Tooltip - content={intl.formatMessage(messages.removefromwatchlist)} + content={intl.formatMessage(globalMessages.addToBlacklist)} > <Button + buttonType={'ghost'} className="z-40 mr-2" buttonSize={'md'} - onClick={onClickDeleteWatchlistBtn} + onClick={() => setShowBlacklistModal(true)} > - {isUpdating ? ( - <Spinner className="h-3" /> - ) : ( - <MinusCircleIcon className={'h-3'} /> - )} + <EyeSlashIcon className={'h-3'} /> </Button> </Tooltip> )} - </> + {data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && ( + <> + {toggleWatchlist ? ( + <Tooltip content={intl.formatMessage(messages.addtowatchlist)}> + <Button + buttonType={'ghost'} + className="z-40 mr-2" + buttonSize={'md'} + onClick={onClickWatchlistBtn} + > + {isUpdating ? ( + <Spinner className="h-3" /> + ) : ( + <StarIcon className={'h-3 text-amber-300'} /> + )} + </Button> + </Tooltip> + ) : ( + <Tooltip + content={intl.formatMessage(messages.removefromwatchlist)} + > + <Button + className="z-40 mr-2" + buttonSize={'md'} + onClick={onClickDeleteWatchlistBtn} + > + {isUpdating ? ( + <Spinner className="h-3" /> + ) : ( + <MinusCircleIcon className={'h-3'} /> + )} + </Button> + </Tooltip> + )} + </> + )} <PlayButton links={mediaLinks} /> <RequestButton mediaType="tv" diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index d797087d0..6aa5ed1da 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -55,6 +55,16 @@ const globalMessages = defineMessages('i18n', { noresults: 'No results.', open: 'Open', resolved: 'Resolved', + blacklist: 'Blacklist', + blacklisted: 'Blacklisted', + blacklistSuccess: '<strong>{title}</strong> was successfully blacklisted.', + blacklistError: 'Something went wrong try again.', + blacklistDuplicateError: + '<strong>{title}</strong> has already been blacklisted.', + removeFromBlacklistSuccess: + '<strong>{title}</strong> 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 <code>{appDataPath}</code> volume mount was not configured properly. All data will be cleared when the container is stopped or restarted.", + "components.Blacklist.blacklistNotFoundError": "<strong>{title}</strong> 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": "<strong>{title}</strong> has already been blacklisted.", + "i18n.blacklistError": "Something went wrong try again.", + "i18n.blacklistSuccess": "<strong>{title}</strong> 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": "<strong>{title}</strong> 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 <Blacklist />; +}; + +export default BlacklistPage;