From 993ae4c58e3d7eee04efb6c67c81c6401d305b7e Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <31414959+0xSysR3ll@users.noreply.github.com> Date: Mon, 30 Mar 2026 00:19:45 +0200 Subject: [PATCH] feat(blocklist): add support for collections (#1841) --- seerr-api.yml | 43 ++++++ server/routes/blocklist.ts | 159 ++++++++++++++++++++- src/components/BlocklistModal/index.tsx | 34 ++++- src/components/CollectionDetails/index.tsx | 155 ++++++++++++++++++-- src/components/StatusBadge/index.tsx | 6 +- src/components/TitleCard/index.tsx | 84 ++++++++--- src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 2 + 8 files changed, 445 insertions(+), 39 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 75ea9f4f..2280324f 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4798,6 +4798,49 @@ paths: responses: '204': description: Succesfully removed media item + /blocklist/collection/{collectionId}: + post: + summary: Add collection to blocklist + description: Adds all movies in a collection to the blocklist + tags: + - blocklist + parameters: + - in: path + name: collectionId + description: Collection ID + required: true + example: '1424991' + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '201': + description: Successfully added collection to blocklist + '500': + description: Error adding collection to blocklist + delete: + summary: Remove collection from blocklist + description: Removes all movies in a collection from the blocklist + tags: + - blocklist + parameters: + - in: path + name: collectionId + description: Collection ID + required: true + example: '1424991' + schema: + type: string + responses: + '204': + description: Successfully removed collection from blocklist + '500': + description: Error removing collection from blocklist /watchlist: post: summary: Add media to watchlist diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index a3ec6abd..39315e8d 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -1,5 +1,6 @@ -import { MediaType } from '@server/constants/media'; -import { getRepository } from '@server/datasource'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import dataSource, { getRepository } from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; import Media from '@server/entity/Media'; import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces'; @@ -7,7 +8,7 @@ import { Permission } from '@server/lib/permissions'; import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; -import { EntityNotFoundError, QueryFailedError } from 'typeorm'; +import { EntityNotFoundError, In, QueryFailedError } from 'typeorm'; import { z } from 'zod'; const blocklistRoutes = Router(); @@ -17,6 +18,7 @@ export const blocklistAdd = z.object({ mediaType: z.nativeEnum(MediaType), title: z.coerce.string().optional(), user: z.coerce.number(), + blocklistedTags: z.string().optional(), }); const blocklistGet = z.object({ @@ -158,6 +160,107 @@ blocklistRoutes.post( } ); +blocklistRoutes.post( + '/collection/:id', + isAuthenticated([Permission.MANAGE_BLOCKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const tmdb = new TheMovieDb(); + const collection = await tmdb.getCollection({ + collectionId: Number(req.params.id), + language: req.locale, + }); + + const uniqueParts = [ + ...new Map(collection.parts.map((p) => [p.id, p])).values(), + ]; + const partIds = uniqueParts.map((p) => p.id); + if (partIds.length === 0) { + return res.status(201).send(); + } + + await dataSource.transaction(async (em) => { + const blocklistRepository = em.getRepository(Blocklist); + const mediaRepository = em.getRepository(Media); + + const [existingBlocklists, existingMedia] = await Promise.all([ + blocklistRepository.find({ + where: { tmdbId: In(partIds), mediaType: MediaType.MOVIE }, + }), + mediaRepository.find({ + where: { tmdbId: In(partIds), mediaType: MediaType.MOVIE }, + }), + ]); + const blocklistByTmdbId = new Map( + existingBlocklists.map((b) => [b.tmdbId, b]) + ); + const mediaByTmdbId = new Map(existingMedia.map((m) => [m.tmdbId, m])); + + await Promise.all( + uniqueParts.map(async (part) => { + if (blocklistByTmdbId.has(part.id)) { + return; + } + + let blocklist = new Blocklist({ + tmdbId: part.id, + mediaType: MediaType.MOVIE, + title: part.title, + user: req.user, + }); + + try { + await blocklistRepository.save(blocklist); + } catch (error) { + if ( + !(error instanceof QueryFailedError) || + error.driverError.errno !== 19 + ) { + throw error; + } + const row = await blocklistRepository.findOne({ + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, + }); + if (!row) { + throw error; + } + blocklist = row; + } + + let media = mediaByTmdbId.get(part.id); + if (!media) { + media = new Media({ + tmdbId: part.id, + status: MediaStatus.BLOCKLISTED, + status4k: MediaStatus.BLOCKLISTED, + mediaType: MediaType.MOVIE, + blocklist: Promise.resolve(blocklist), + }); + } else { + media.status = MediaStatus.BLOCKLISTED; + media.status4k = MediaStatus.BLOCKLISTED; + media.blocklist = Promise.resolve(blocklist); + } + + await mediaRepository.save(media); + }) + ); + }); + + return res.status(201).send(); + } catch (e) { + logger.error('Error blocklisting collection', { + label: 'Blocklist', + errorMessage: e.message, + collectionId: req.params.id, + }); + return next({ status: 500, message: e.message }); + } + } +); + blocklistRoutes.delete( '/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { @@ -208,4 +311,54 @@ blocklistRoutes.delete( } ); +blocklistRoutes.delete( + '/collection/:id', + isAuthenticated([Permission.MANAGE_BLOCKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const tmdb = new TheMovieDb(); + const collection = await tmdb.getCollection({ + collectionId: Number(req.params.id), + language: req.locale, + }); + + await dataSource.transaction(async (em) => { + const blocklistRepository = em.getRepository(Blocklist); + const mediaRepository = em.getRepository(Media); + + await Promise.all( + collection.parts.map(async (part) => { + const blocklistItem = await blocklistRepository.findOne({ + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, + }); + + if (blocklistItem) { + await blocklistRepository.remove(blocklistItem); + + const mediaItem = await mediaRepository.findOne({ + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, + }); + + if (mediaItem) { + await mediaRepository.remove(mediaItem); + } + } + }) + ); + }); + + return res.status(204).send(); + } catch (e) { + logger.error('Error unblocklisting collection', { + label: 'Blocklist', + errorMessage: e.message, + collectionId: req.params.id, + }); + return next({ status: 500, message: e.message }); + } + } +); + export default blocklistRoutes; diff --git a/src/components/BlocklistModal/index.tsx b/src/components/BlocklistModal/index.tsx index 01fb1d1b..0c386c8f 100644 --- a/src/components/BlocklistModal/index.tsx +++ b/src/components/BlocklistModal/index.tsx @@ -2,6 +2,8 @@ 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 { Collection } from '@server/models/Collection'; import type { MovieDetails } from '@server/models/Movie'; import type { TvDetails } from '@server/models/Tv'; import axios from 'axios'; @@ -21,8 +23,18 @@ const messages = defineMessages('component.BlocklistModal', { blocklisting: 'Blocklisting', }); +const isCollection = ( + data: MovieDetails | TvDetails | Collection | null +): data is Collection => { + return ( + data !== null && + data !== undefined && + (data as Collection).parts !== undefined + ); +}; + const isMovie = ( - movie: MovieDetails | TvDetails | null + movie: MovieDetails | TvDetails | Collection | null ): movie is MovieDetails => { if (!movie) return false; return (movie as MovieDetails).title !== undefined; @@ -37,7 +49,9 @@ const BlocklistModal = ({ isUpdating, }: BlocklistModalProps) => { const intl = useIntl(); - const [data, setData] = useState(null); + const [data, setData] = useState< + TvDetails | MovieDetails | Collection | null + >(null); const [error, setError] = useState(null); useEffect(() => { @@ -68,11 +82,19 @@ const BlocklistModal = ({ loading={!data && !error} backgroundClickable title={`${intl.formatMessage(globalMessages.blocklist)} ${ - isMovie(data) - ? intl.formatMessage(globalMessages.movie) - : intl.formatMessage(globalMessages.tvshow) + type === 'collection' + ? intl.formatMessage(globalMessages.collection) + : isMovie(data) + ? intl.formatMessage(globalMessages.movie) + : intl.formatMessage(globalMessages.tvshow) + }`} + subTitle={`${ + isCollection(data) + ? data.name + : isMovie(data) + ? data.title + : data?.name }`} - subTitle={`${isMovie(data) ? data.title : data?.name}`} onCancel={onCancel} onOk={onComplete} okText={ diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index e1e6fc2d..191c39b9 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,7 +1,10 @@ +import BlocklistModal from '@app/components/BlocklistModal'; +import Button from '@app/components/Common/Button'; import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; +import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import Slider from '@app/components/Slider'; import StatusBadge from '@app/components/StatusBadge'; @@ -12,19 +15,27 @@ import globalMessages from '@app/i18n/globalMessages'; import ErrorPage from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper'; -import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'; +import { + ArrowDownTrayIcon, + EyeIcon, + EyeSlashIcon, +} from '@heroicons/react/24/outline'; import { MediaStatus } from '@server/constants/media'; import type { Collection } from '@server/models/Collection'; +import axios from 'axios'; import { uniq } from 'lodash'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; import useSWR from 'swr'; const messages = defineMessages('components.CollectionDetails', { overview: 'Overview', numberofmovies: '{count} Movies', + removefromblocklistpartialcount: + '{removeLabel} ({count, plural, one {# movie} other {# movies}})', requestcollection: 'Request Collection', requestcollection4k: 'Request Collection in 4K', }); @@ -40,6 +51,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [is4k, setIs4k] = useState(false); + const [showBlocklistModal, setShowBlocklistModal] = useState(false); + const [isBlocklistUpdating, setIsBlocklistUpdating] = useState(false); + const { addToast } = useToasts(); const returnCollectionDownloadItems = (data: Collection | undefined) => { const [downloadStatus, downloadStatus4k] = [ @@ -70,6 +84,63 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { data: genres } = useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); + const onClickHideItemBtn = async (): Promise => { + setIsBlocklistUpdating(true); + + try { + await axios.post(`/api/v1/blocklist/collection/${data?.id}`); + + addToast( + + {intl.formatMessage(globalMessages.blocklistSuccess, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } catch { + addToast(intl.formatMessage(globalMessages.blocklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlocklistUpdating(false); + setShowBlocklistModal(false); + }; + + const onClickUnblocklistBtn = async (): Promise => { + if (!data) return; + + setIsBlocklistUpdating(true); + + try { + await axios.delete(`/api/v1/blocklist/collection/${data.id}`); + + addToast( + + {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { + title: data.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } catch { + addToast(intl.formatMessage(globalMessages.blocklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlocklistUpdating(false); + }; + const [downloadStatus, downloadStatus4k] = useMemo(() => { const downloadItems = returnCollectionDownloadItems(data); return [downloadItems.downloadStatus, downloadItems.downloadStatus4k]; @@ -97,7 +168,17 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { let collectionStatus = MediaStatus.UNKNOWN; let collectionStatus4k = MediaStatus.UNKNOWN; - if ( + const blocklistedParts = data.parts.filter( + (part) => + part.mediaInfo && part.mediaInfo.status === MediaStatus.BLOCKLISTED + ); + const isCollectionBlocklisted = blocklistedParts.length > 0; + const isCollectionPartiallyBlocklisted = + blocklistedParts.length > 0 && blocklistedParts.length < data.parts.length; + + if (isCollectionBlocklisted) { + collectionStatus = MediaStatus.BLOCKLISTED; + } else if ( data.parts.every( (part) => part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE @@ -152,6 +233,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { part.mediaInfo.status4k === MediaStatus.UNKNOWN ).length > 0; + const blocklistVisibility = hasPermission( + [Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], + { type: 'or' } + ); + const collectionAttributes: React.ReactNode[] = []; collectionAttributes.push( @@ -188,11 +274,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); } - const blocklistVisibility = hasPermission( - [Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], - { type: 'or' } - ); - return (
{ }} onCancel={() => setRequestModal(false)} /> + setShowBlocklistModal(false)} + onComplete={onClickHideItemBtn} + isUpdating={isBlocklistUpdating} + /> +
{ status={collectionStatus} downloadItem={downloadStatus} title={titles} + statusLabelOverride={ + isCollectionPartiallyBlocklisted + ? intl.formatMessage(globalMessages.partiallyblocklisted) + : undefined + } inProgress={data.parts.some( (part) => (part.mediaInfo?.downloadStatus ?? []).length > 0 )} @@ -292,6 +387,48 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
+ {hasPermission([Permission.MANAGE_BLOCKLIST], { type: 'or' }) && + (isCollectionBlocklisted ? ( + + + + ) : ( + + + + ))} {(hasRequestable || hasRequestable4k) && ( { isEmpty={data.parts.length === 0} items={data.parts .filter((title) => { - if (!blocklistVisibility) + if (!blocklistVisibility) { return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED; + } return title; }) .map((title) => ( @@ -365,6 +503,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { userScore={title.voteAverage} year={title.releaseDate} mediaType={title.mediaType} + mutateParent={revalidate} /> ))} /> diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 9144b4b9..faea1fb5 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -31,6 +31,7 @@ interface StatusBadgeProps { tmdbId?: number; mediaType?: 'movie' | 'tv'; title?: string | string[]; + statusLabelOverride?: string; } const StatusBadge = ({ @@ -43,6 +44,7 @@ const StatusBadge = ({ tmdbId, mediaType, title, + statusLabelOverride, }: StatusBadgeProps) => { const intl = useIntl(); const { hasPermission } = useUser(); @@ -364,7 +366,9 @@ const StatusBadge = ({ {intl.formatMessage(is4k ? messages.status4k : messages.status, { - status: intl.formatMessage(globalMessages.blocklisted), + status: + statusLabelOverride ?? + intl.formatMessage(globalMessages.blocklisted), })} diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index c1aafaf7..fd81ef78 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -175,12 +175,16 @@ const TitleCard = ({ if (topNode) { try { - await axios.post('/api/v1/blocklist', { - tmdbId: id, - mediaType, - title, - user: user?.id, - }); + if (mediaType === 'collection') { + await axios.post(`/api/v1/blocklist/collection/${id}`); + } else { + await axios.post('/api/v1/blocklist', { + tmdbId: id, + mediaType, + title, + user: user?.id, + }); + } addToast( {intl.formatMessage(globalMessages.blocklistSuccess, { @@ -191,6 +195,9 @@ const TitleCard = ({ { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.BLOCKLISTED); + if (mutateParent) { + mutateParent(); + } } catch (e) { if (e?.response?.status === 412) { addToast( @@ -225,22 +232,57 @@ const TitleCard = ({ const topNode = cardRef.current; if (topNode) { - const res = await axios.delete( - `/api/v1/blocklist/${id}?mediaType=${mediaType}` - ); + try { + if (mediaType === 'collection') { + const res = await axios.delete(`/api/v1/blocklist/collection/${id}`); - if (res.status === 204) { - addToast( - - {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { - title, - strong: (msg: React.ReactNode) => {msg}, - })} - , - { appearance: 'success', autoDismiss: true } - ); - setCurrentStatus(MediaStatus.UNKNOWN); - } else { + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.UNKNOWN); + if (mutateParent) { + mutateParent(); + } + } else { + addToast(intl.formatMessage(globalMessages.blocklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } else { + const res = await axios.delete( + `/api/v1/blocklist/${id}?mediaType=${mediaType}` + ); + + if (res.status === 204) { + addToast( + + {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { + title, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + setCurrentStatus(MediaStatus.UNKNOWN); + if (mutateParent) { + mutateParent(); + } + } else { + addToast(intl.formatMessage(globalMessages.blocklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } + } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 8b716d19..352c30ea 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -60,6 +60,7 @@ const globalMessages = defineMessages('i18n', { resolved: 'Resolved', blocklist: 'Blocklist', blocklisted: 'Blocklisted', + partiallyblocklisted: 'Partially Blocklisted', blocklistSuccess: '{title} was successfully blocklisted.', blocklistError: 'Something went wrong. Please try again.', blocklistDuplicateError: diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 6d77897b..738d5599 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -18,6 +18,7 @@ "components.Blocklist.showAllBlocklisted": "Show All Blocklisted Media", "components.CollectionDetails.numberofmovies": "{count} Movies", "components.CollectionDetails.overview": "Overview", + "components.CollectionDetails.removefromblocklistpartialcount": "{removeLabel} ({count, plural, one {# movie} other {# movies}})", "components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requestcollection4k": "Request Collection in 4K", "components.Discover.CreateSlider.addSlider": "Add Slider", @@ -1601,6 +1602,7 @@ "i18n.notrequested": "Not Requested", "i18n.open": "Open", "i18n.partiallyavailable": "Partially Available", + "i18n.partiallyblocklisted": "Partially Blocklisted", "i18n.pending": "Pending", "i18n.previous": "Previous", "i18n.processing": "Processing",