From 611a547a23782fe5a33ea15b1de3ed362ddb5e58 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 8 Aug 2025 22:30:43 +0200 Subject: [PATCH 01/25] feat(blacklist): add support for collections This PR adds support for blacklisting entire collections. --- seerr-api.yml | 43 +++++++ server/routes/blocklist.ts | 98 ++++++++++++---- src/components/BlocklistModal/index.tsx | 34 +++++- src/components/CollectionDetails/index.tsx | 125 ++++++++++++++++++++- src/components/TitleCard/index.tsx | 84 ++++++++++---- 5 files changed, 332 insertions(+), 52 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 75ea9f4f94..4cb58ac751 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4798,6 +4798,49 @@ paths: responses: '204': description: Succesfully removed media item + /blacklist/collection/{collectionId}: + post: + summary: Add collection to blacklist + description: Adds all movies in a collection to the blacklist + tags: + - blacklist + 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 blacklist + '500': + description: Error adding collection to blacklist + delete: + summary: Remove collection from blacklist + description: Removes all movies in a collection from the blacklist + tags: + - blacklist + parameters: + - in: path + name: collectionId + description: Collection ID + required: true + example: '1424991' + schema: + type: string + responses: + '204': + description: Successfully removed collection from blacklist + '500': + description: Error removing collection from blacklist /watchlist: post: summary: Add media to watchlist diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index a3ec6abd09..4ec7fa9208 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -1,4 +1,5 @@ -import { MediaType } from '@server/constants/media'; +import TheMovieDb from '@server/api/themoviedb'; +import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { Blocklist } from '@server/entity/Blocklist'; import Media from '@server/entity/Media'; @@ -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({ @@ -129,6 +131,14 @@ blocklistRoutes.post( try { const values = blocklistAdd.parse(req.body); + const existingBlacklist = await getRepository(Blacklist).findOne({ + where: { tmdbId: values.tmdbId }, + }); + + if (existingBlacklist) { + return next({ status: 412, message: 'Item already blacklisted' }); + } + await Blocklist.addToBlocklist({ blocklistRequest: values, }); @@ -158,42 +168,84 @@ blocklistRoutes.post( } ); -blocklistRoutes.delete( +blacklistRoutes.delete( '/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { type: 'or', }), async (req, res, next) => { - const mediaType = req.query.mediaType; - if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) { - return next({ - status: 400, - message: 'Invalid or missing mediaType query parameter.', + try { + const tmdb = new TheMovieDb(); + const collection = await tmdb.getCollection({ + collectionId: Number(req.params.id), + language: req.locale, }); - } - try { - const blocklisteRepository = getRepository(Blocklist); + const blocklistRepository = getRepository(Blocklist); + const mediaRepository = getRepository(Media); - const blocklistItem = await blocklisteRepository.findOneOrFail({ - where: { - tmdbId: Number(req.params.id), - mediaType, - }, - }); + // Remove all movies in the collection from blocklist + await Promise.all( + collection.parts.map(async (part) => { + const blocklistItem = await blocklistRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (blocklistItem) { + await blocklistRepository.remove(blocklistItem); + + const mediaItem = await mediaRepository.findOne({ + where: { tmdbId: part.id }, + }); - await blocklisteRepository.remove(blocklistItem); + if (mediaItem) { + mediaItem.status = MediaStatus.UNKNOWN; + mediaItem.status4k = MediaStatus.UNKNOWN; + await mediaRepository.save(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 }); + } + } +); + +blocklistRoutes.delete( + '/:id', + isAuthenticated([Permission.MANAGE_BLOCKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const blocklistRepository = getRepository(Blocklist); const mediaRepository = getRepository(Media); - const mediaItem = await mediaRepository.findOneOrFail({ - where: { - tmdbId: Number(req.params.id), - mediaType: req.query.mediaType as MediaType, - }, + const blocklistItem = await blocklistRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, }); - await mediaRepository.remove(mediaItem); + await blocklistRepository.remove(blocklistItem); + + try { + const mediaItem = await mediaRepository.findOneOrFail({ + where: { tmdbId: Number(req.params.id) }, + }); + + mediaItem.status = MediaStatus.UNKNOWN; + mediaItem.status4k = MediaStatus.UNKNOWN; + await mediaRepository.save(mediaItem); + } catch (mediaError) { + // Media entity doesn't exist, which is fine + } return res.status(204).send(); } catch (e) { diff --git a/src/components/BlocklistModal/index.tsx b/src/components/BlocklistModal/index.tsx index 01fb1d1bff..7361fbd5fc 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 e1e6fc2d6d..a3a271ddfc 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,7 +1,10 @@ +import BlacklistModal from '@app/components/BlacklistModal'; +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,14 +15,20 @@ 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', { @@ -40,6 +49,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [is4k, setIs4k] = useState(false); + const [showBlacklistModal, setShowBlacklistModal] = useState(false); + const [isBlacklistUpdating, setIsBlacklistUpdating] = useState(false); + const { addToast } = useToasts(); const returnCollectionDownloadItems = (data: Collection | undefined) => { const [downloadStatus, downloadStatus4k] = [ @@ -70,6 +82,63 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { data: genres } = useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); + const onClickHideItemBtn = async (): Promise => { + setIsBlacklistUpdating(true); + + try { + await axios.post(`/api/v1/blacklist/collection/${data?.id}`); + + addToast( + + {intl.formatMessage(globalMessages.blacklistSuccess, { + title: data?.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } catch (e) { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + setShowBlacklistModal(false); + }; + + const onClickUnblacklistBtn = async (): Promise => { + if (!data) return; + + setIsBlacklistUpdating(true); + + try { + await axios.delete(`/api/v1/blacklist/collection/${data.id}`); + + addToast( + + {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + title: data.name, + strong: (msg: React.ReactNode) => {msg}, + })} + , + { appearance: 'success', autoDismiss: true } + ); + + revalidate(); + } catch (e) { + addToast(intl.formatMessage(globalMessages.blacklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + + setIsBlacklistUpdating(false); + }; + const [downloadStatus, downloadStatus4k] = useMemo(() => { const downloadItems = returnCollectionDownloadItems(data); return [downloadItems.downloadStatus, downloadItems.downloadStatus4k]; @@ -97,7 +166,15 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { let collectionStatus = MediaStatus.UNKNOWN; let collectionStatus4k = MediaStatus.UNKNOWN; - if ( + const blacklistedParts = data.parts.filter( + (part) => + part.mediaInfo && part.mediaInfo.status === MediaStatus.BLACKLISTED + ); + const isCollectionBlacklisted = blacklistedParts.length > 0; + + if (isCollectionBlacklisted) { + collectionStatus = MediaStatus.BLACKLISTED; + } else if ( data.parts.every( (part) => part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE @@ -231,6 +308,15 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { }} onCancel={() => setRequestModal(false)} /> + setShowBlacklistModal(false)} + onComplete={onClickHideItemBtn} + isUpdating={isBlacklistUpdating} + /> +
{
+ {blacklistVisibility && + (isCollectionBlacklisted ? ( + + + + ) : ( + + + + ))} {(hasRequestable || hasRequestable4k) && ( { + if (mediaType === 'collection' && !status) { + // We could add a check here to determine collection blacklist status + // For now, we'll rely on the status prop being passed from parent components + } + }, [mediaType, status]); + const requestComplete = useCallback((newStatus: MediaStatus) => { setCurrentStatus(newStatus); setShowRequestModal(false); @@ -175,12 +184,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, { @@ -225,22 +238,51 @@ 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); + } 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); + } else { + addToast(intl.formatMessage(globalMessages.blocklistError), { + appearance: 'error', + autoDismiss: true, + }); + } + } + } catch (e) { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, From 1f8ef230f11392999939262d0e78c4317a1912fe Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 8 Aug 2025 22:38:26 +0200 Subject: [PATCH 02/25] fix(blacklist): remove unecessary POST data --- src/components/TitleCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index fe882cb214..fd9914bc56 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -185,7 +185,7 @@ const TitleCard = ({ if (topNode) { try { if (mediaType === 'collection') { - await axios.post(`/api/v1/blocklist/collection/${id}`); + await axios.post(`/api/v1/blacklist/collection/${id}`); } else { await axios.post('/api/v1/blocklist', { tmdbId: id, From 0a8ae7bb78ec12100a8334325cc6b638f3099eda Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Fri, 8 Aug 2025 22:49:22 +0200 Subject: [PATCH 03/25] revert(blacklist): bring back original implementation --- server/routes/blacklist.ts | 320 +++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 server/routes/blacklist.ts diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts new file mode 100644 index 0000000000..0a35697480 --- /dev/null +++ b/server/routes/blacklist.ts @@ -0,0 +1,320 @@ +import TheMovieDb from '@server/api/themoviedb'; +import { MediaStatus, MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import { Blacklist } from '@server/entity/Blacklist'; +import Media from '@server/entity/Media'; +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 { EntityNotFoundError, 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(), +}); + +const blacklistGet = z.object({ + take: z.coerce.number().int().positive().default(25), + skip: z.coerce.number().int().nonnegative().default(0), + search: z.string().optional(), + filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(), +}); + +blacklistRoutes.get( + '/', + isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + const { take, skip, search, filter } = blacklistGet.parse(req.query); + + try { + let query = getRepository(Blacklist) + .createQueryBuilder('blacklist') + .leftJoinAndSelect('blacklist.user', 'user') + .where('1 = 1'); // Allow use of andWhere later + + switch (filter) { + case 'manual': + query = query.andWhere('blacklist.blacklistedTags IS NULL'); + break; + case 'blacklistedTags': + query = query.andWhere('blacklist.blacklistedTags IS NOT NULL'); + break; + } + + if (search) { + query = query.andWhere('blacklist.title like :title', { + title: `%${search}%`, + }); + } + + const [blacklistedItems, itemsCount] = await query + .orderBy('blacklist.createdAt', 'DESC') + .take(take) + .skip(skip) + .getManyAndCount(); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(itemsCount / take), + pageSize: take, + results: itemsCount, + page: Math.ceil(skip / take) + 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.get( + '/: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) }, + }); + + return res.status(200).send(blacklistItem); + } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + +blacklistRoutes.post( + '/', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + type: 'or', + }), + async (req, res, next) => { + try { + const values = blacklistAdd.parse(req.body); + + const existingBlacklist = await getRepository(Blacklist).findOne({ + where: { tmdbId: values.tmdbId }, + }); + + if (existingBlacklist) { + return next({ status: 412, message: 'Item already blacklisted' }); + } + + await Blacklist.addToBlacklist({ + blacklistRequest: { + tmdbId: values.tmdbId, + mediaType: values.mediaType as MediaType, + title: values.title, + }, + }); + + 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.post( + '/collection/:id', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + 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 blacklistRepository = getRepository(Blacklist); + const mediaRepository = getRepository(Media); + + // Blacklist all movies in the collection + await Promise.all( + collection.parts.map(async (part) => { + const existingBlacklist = await blacklistRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (existingBlacklist) { + return; + } + + const blacklist = new Blacklist({ + tmdbId: part.id, + mediaType: MediaType.MOVIE, + title: part.title, + user: req.user, + }); + + await blacklistRepository.save(blacklist); + + let media = await mediaRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (!media) { + media = new Media({ + tmdbId: part.id, + status: MediaStatus.BLACKLISTED, + status4k: MediaStatus.BLACKLISTED, + mediaType: MediaType.MOVIE, + blacklist: Promise.resolve(blacklist), + }); + } else { + media.status = MediaStatus.BLACKLISTED; + media.status4k = MediaStatus.BLACKLISTED; + media.blacklist = Promise.resolve(blacklist); + } + + await mediaRepository.save(media); + }) + ); + + return res.status(201).send(); + } catch (e) { + logger.error('Error blacklisting collection', { + label: 'Blacklist', + errorMessage: e.message, + collectionId: req.params.id, + }); + return next({ status: 500, message: e.message }); + } + } +); + +blacklistRoutes.delete( + '/collection/:id', + isAuthenticated([Permission.MANAGE_BLACKLIST], { + 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 blacklistRepository = getRepository(Blacklist); + const mediaRepository = getRepository(Media); + + // Remove all movies in the collection from blacklist + await Promise.all( + collection.parts.map(async (part) => { + const blacklistItem = await blacklistRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (blacklistItem) { + await blacklistRepository.remove(blacklistItem); + + const mediaItem = await mediaRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (mediaItem) { + mediaItem.status = MediaStatus.UNKNOWN; + mediaItem.status4k = MediaStatus.UNKNOWN; + await mediaRepository.save(mediaItem); + } + } + }) + ); + + return res.status(204).send(); + } catch (e) { + logger.error('Error unblacklisting collection', { + label: 'Blacklist', + errorMessage: e.message, + collectionId: req.params.id, + }); + return next({ status: 500, message: e.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 EntityNotFoundError) { + return next({ + status: 401, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + +export default blacklistRoutes; From 75a7624f74c067009decb92611cc7bd8c73cd4f2 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 21:40:40 +0100 Subject: [PATCH 04/25] refactor(blacklist): remove blacklist route implementation --- server/routes/blacklist.ts | 320 ------------------------------------- 1 file changed, 320 deletions(-) delete mode 100644 server/routes/blacklist.ts diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts deleted file mode 100644 index 0a35697480..0000000000 --- a/server/routes/blacklist.ts +++ /dev/null @@ -1,320 +0,0 @@ -import TheMovieDb from '@server/api/themoviedb'; -import { MediaStatus, MediaType } from '@server/constants/media'; -import { getRepository } from '@server/datasource'; -import { Blacklist } from '@server/entity/Blacklist'; -import Media from '@server/entity/Media'; -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 { EntityNotFoundError, 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(), -}); - -const blacklistGet = z.object({ - take: z.coerce.number().int().positive().default(25), - skip: z.coerce.number().int().nonnegative().default(0), - search: z.string().optional(), - filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(), -}); - -blacklistRoutes.get( - '/', - isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], { - type: 'or', - }), - async (req, res, next) => { - const { take, skip, search, filter } = blacklistGet.parse(req.query); - - try { - let query = getRepository(Blacklist) - .createQueryBuilder('blacklist') - .leftJoinAndSelect('blacklist.user', 'user') - .where('1 = 1'); // Allow use of andWhere later - - switch (filter) { - case 'manual': - query = query.andWhere('blacklist.blacklistedTags IS NULL'); - break; - case 'blacklistedTags': - query = query.andWhere('blacklist.blacklistedTags IS NOT NULL'); - break; - } - - if (search) { - query = query.andWhere('blacklist.title like :title', { - title: `%${search}%`, - }); - } - - const [blacklistedItems, itemsCount] = await query - .orderBy('blacklist.createdAt', 'DESC') - .take(take) - .skip(skip) - .getManyAndCount(); - - return res.status(200).json({ - pageInfo: { - pages: Math.ceil(itemsCount / take), - pageSize: take, - results: itemsCount, - page: Math.ceil(skip / take) + 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.get( - '/: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) }, - }); - - return res.status(200).send(blacklistItem); - } catch (e) { - if (e instanceof EntityNotFoundError) { - return next({ - status: 401, - message: e.message, - }); - } - return next({ status: 500, message: e.message }); - } - } -); - -blacklistRoutes.post( - '/', - isAuthenticated([Permission.MANAGE_BLACKLIST], { - type: 'or', - }), - async (req, res, next) => { - try { - const values = blacklistAdd.parse(req.body); - - const existingBlacklist = await getRepository(Blacklist).findOne({ - where: { tmdbId: values.tmdbId }, - }); - - if (existingBlacklist) { - return next({ status: 412, message: 'Item already blacklisted' }); - } - - await Blacklist.addToBlacklist({ - blacklistRequest: { - tmdbId: values.tmdbId, - mediaType: values.mediaType as MediaType, - title: values.title, - }, - }); - - 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.post( - '/collection/:id', - isAuthenticated([Permission.MANAGE_BLACKLIST], { - 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 blacklistRepository = getRepository(Blacklist); - const mediaRepository = getRepository(Media); - - // Blacklist all movies in the collection - await Promise.all( - collection.parts.map(async (part) => { - const existingBlacklist = await blacklistRepository.findOne({ - where: { tmdbId: part.id }, - }); - - if (existingBlacklist) { - return; - } - - const blacklist = new Blacklist({ - tmdbId: part.id, - mediaType: MediaType.MOVIE, - title: part.title, - user: req.user, - }); - - await blacklistRepository.save(blacklist); - - let media = await mediaRepository.findOne({ - where: { tmdbId: part.id }, - }); - - if (!media) { - media = new Media({ - tmdbId: part.id, - status: MediaStatus.BLACKLISTED, - status4k: MediaStatus.BLACKLISTED, - mediaType: MediaType.MOVIE, - blacklist: Promise.resolve(blacklist), - }); - } else { - media.status = MediaStatus.BLACKLISTED; - media.status4k = MediaStatus.BLACKLISTED; - media.blacklist = Promise.resolve(blacklist); - } - - await mediaRepository.save(media); - }) - ); - - return res.status(201).send(); - } catch (e) { - logger.error('Error blacklisting collection', { - label: 'Blacklist', - errorMessage: e.message, - collectionId: req.params.id, - }); - return next({ status: 500, message: e.message }); - } - } -); - -blacklistRoutes.delete( - '/collection/:id', - isAuthenticated([Permission.MANAGE_BLACKLIST], { - 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 blacklistRepository = getRepository(Blacklist); - const mediaRepository = getRepository(Media); - - // Remove all movies in the collection from blacklist - await Promise.all( - collection.parts.map(async (part) => { - const blacklistItem = await blacklistRepository.findOne({ - where: { tmdbId: part.id }, - }); - - if (blacklistItem) { - await blacklistRepository.remove(blacklistItem); - - const mediaItem = await mediaRepository.findOne({ - where: { tmdbId: part.id }, - }); - - if (mediaItem) { - mediaItem.status = MediaStatus.UNKNOWN; - mediaItem.status4k = MediaStatus.UNKNOWN; - await mediaRepository.save(mediaItem); - } - } - }) - ); - - return res.status(204).send(); - } catch (e) { - logger.error('Error unblacklisting collection', { - label: 'Blacklist', - errorMessage: e.message, - collectionId: req.params.id, - }); - return next({ status: 500, message: e.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 EntityNotFoundError) { - return next({ - status: 401, - message: e.message, - }); - } - return next({ status: 500, message: e.message }); - } - } -); - -export default blacklistRoutes; From adc12e70d9243fedf45aaa007ef076e025f3a07c Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 21:49:49 +0100 Subject: [PATCH 05/25] refactor(blacklist): rename blacklist to blocklist --- src/components/CollectionDetails/index.tsx | 66 +++++++++++----------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index a3a271ddfc..d7517aac8f 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,4 +1,4 @@ -import BlacklistModal from '@app/components/BlacklistModal'; +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'; @@ -49,8 +49,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { const { hasPermission } = useUser(); const [requestModal, setRequestModal] = useState(false); const [is4k, setIs4k] = useState(false); - const [showBlacklistModal, setShowBlacklistModal] = useState(false); - const [isBlacklistUpdating, setIsBlacklistUpdating] = useState(false); + const [showBlocklistModal, setShowBlocklistModal] = useState(false); + const [isBlocklistUpdating, setIsBlocklistUpdating] = useState(false); const { addToast } = useToasts(); const returnCollectionDownloadItems = (data: Collection | undefined) => { @@ -83,14 +83,14 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`); const onClickHideItemBtn = async (): Promise => { - setIsBlacklistUpdating(true); + setIsBlocklistUpdating(true); try { - await axios.post(`/api/v1/blacklist/collection/${data?.id}`); + await axios.post(`/api/v1/blocklist/collection/${data?.id}`); addToast( - {intl.formatMessage(globalMessages.blacklistSuccess, { + {intl.formatMessage(globalMessages.blocklistSuccess, { title: data?.name, strong: (msg: React.ReactNode) => {msg}, })} @@ -100,27 +100,27 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { revalidate(); } catch (e) { - addToast(intl.formatMessage(globalMessages.blacklistError), { + addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, }); } - setIsBlacklistUpdating(false); - setShowBlacklistModal(false); + setIsBlocklistUpdating(false); + setShowBlocklistModal(false); }; const onClickUnblacklistBtn = async (): Promise => { if (!data) return; - setIsBlacklistUpdating(true); + setIsBlocklistUpdating(true); try { - await axios.delete(`/api/v1/blacklist/collection/${data.id}`); + await axios.delete(`/api/v1/blocklist/collection/${data.id}`); addToast( - {intl.formatMessage(globalMessages.removeFromBlacklistSuccess, { + {intl.formatMessage(globalMessages.removeFromBlocklistSuccess, { title: data.name, strong: (msg: React.ReactNode) => {msg}, })} @@ -130,13 +130,13 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { revalidate(); } catch (e) { - addToast(intl.formatMessage(globalMessages.blacklistError), { + addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, }); } - setIsBlacklistUpdating(false); + setIsBlocklistUpdating(false); }; const [downloadStatus, downloadStatus4k] = useMemo(() => { @@ -166,14 +166,14 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { let collectionStatus = MediaStatus.UNKNOWN; let collectionStatus4k = MediaStatus.UNKNOWN; - const blacklistedParts = data.parts.filter( + const blocklistedParts = data.parts.filter( (part) => - part.mediaInfo && part.mediaInfo.status === MediaStatus.BLACKLISTED + part.mediaInfo && part.mediaInfo.status === MediaStatus.BLOCKLISTED ); - const isCollectionBlacklisted = blacklistedParts.length > 0; + const isCollectionBlocklisted = blocklistedParts.length > 0; - if (isCollectionBlacklisted) { - collectionStatus = MediaStatus.BLACKLISTED; + if (isCollectionBlocklisted) { + collectionStatus = MediaStatus.BLOCKLISTED; } else if ( data.parts.every( (part) => @@ -308,13 +308,13 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { }} onCancel={() => setRequestModal(false)} /> - setShowBlacklistModal(false)} + show={showBlocklistModal} + onCancel={() => setShowBlocklistModal(false)} onComplete={onClickHideItemBtn} - isUpdating={isBlacklistUpdating} + isUpdating={isBlocklistUpdating} />
@@ -378,14 +378,14 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
- {blacklistVisibility && - (isCollectionBlacklisted ? ( + {blocklistVisibility && + (isCollectionBlocklisted ? ( ) : ( From a667ebb6cde28fdf60c5da226588be48010bb99e Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 21:53:51 +0100 Subject: [PATCH 06/25] fix(blocklist): remove unused caught error variables --- server/routes/blocklist.ts | 2 +- src/components/CollectionDetails/index.tsx | 4 ++-- src/components/TitleCard/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index 4ec7fa9208..cefc9b3661 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -243,7 +243,7 @@ blocklistRoutes.delete( mediaItem.status = MediaStatus.UNKNOWN; mediaItem.status4k = MediaStatus.UNKNOWN; await mediaRepository.save(mediaItem); - } catch (mediaError) { + } catch { // Media entity doesn't exist, which is fine } diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index d7517aac8f..d4bd455018 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -99,7 +99,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); revalidate(); - } catch (e) { + } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, @@ -129,7 +129,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); revalidate(); - } catch (e) { + } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index fd9914bc56..a6f8a4b5d0 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -282,7 +282,7 @@ const TitleCard = ({ }); } } - } catch (e) { + } catch { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', autoDismiss: true, From 8594e71fc9d524706a692b124cd4e0fcbe83c8e3 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 22:04:04 +0100 Subject: [PATCH 07/25] feat(blocklist): add partial blocklist status --- src/components/BlocklistModal/index.tsx | 4 ++-- src/components/CollectionDetails/index.tsx | 7 +++++++ src/components/StatusBadge/index.tsx | 6 +++++- src/i18n/globalMessages.ts | 1 + src/i18n/locale/en.json | 1 + 5 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/BlocklistModal/index.tsx b/src/components/BlocklistModal/index.tsx index 7361fbd5fc..0c386c8f2a 100644 --- a/src/components/BlocklistModal/index.tsx +++ b/src/components/BlocklistModal/index.tsx @@ -92,8 +92,8 @@ const BlocklistModal = ({ isCollection(data) ? data.name : isMovie(data) - ? data.title - : data?.name + ? data.title + : data?.name }`} onCancel={onCancel} onOk={onComplete} diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index d4bd455018..ec3288aefe 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -171,6 +171,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { 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; @@ -340,6 +342,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { status={collectionStatus} downloadItem={downloadStatus} title={titles} + statusLabelOverride={ + isCollectionPartiallyBlocklisted + ? intl.formatMessage(globalMessages.partiallyblocklisted) + : undefined + } inProgress={data.parts.some( (part) => (part.mediaInfo?.downloadStatus ?? []).length > 0 )} diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index 9144b4b9ae..faea1fb5c2 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/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 8b716d193d..352c30ea7d 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 6d77897be4..bb3f06c78a 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1601,6 +1601,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", From a5f51b0b515c6bc3215744c83ac999cb5e00b442 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 22:08:20 +0100 Subject: [PATCH 08/25] refactor: remove blacklist related code --- server/routes/blocklist.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index cefc9b3661..311b940e5c 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -131,14 +131,6 @@ blocklistRoutes.post( try { const values = blocklistAdd.parse(req.body); - const existingBlacklist = await getRepository(Blacklist).findOne({ - where: { tmdbId: values.tmdbId }, - }); - - if (existingBlacklist) { - return next({ status: 412, message: 'Item already blacklisted' }); - } - await Blocklist.addToBlocklist({ blocklistRequest: values, }); @@ -168,8 +160,8 @@ blocklistRoutes.post( } ); -blacklistRoutes.delete( - '/:id', +blocklistRoutes.delete( + '/collection/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { type: 'or', }), From bb9668c5a58d2e2711ce546478f7e345190461fc Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 22:31:41 +0100 Subject: [PATCH 09/25] refactor: re-order functions --- server/routes/blocklist.ts | 91 +++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index 311b940e5c..dba50342e1 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -160,6 +160,56 @@ blocklistRoutes.post( } ); +blocklistRoutes.delete( + '/:id', + isAuthenticated([Permission.MANAGE_BLOCKLIST], { + type: 'or', + }), + async (req, res, next) => { + const mediaType = req.query.mediaType; + if (mediaType !== MediaType.MOVIE && mediaType !== MediaType.TV) { + return next({ + status: 400, + message: 'Invalid or missing mediaType query parameter.', + }); + } + + try { + const blocklisteRepository = getRepository(Blocklist); + + const blocklistItem = await blocklisteRepository.findOneOrFail({ + where: { + tmdbId: Number(req.params.id), + mediaType, + }, + }); + + await blocklisteRepository.remove(blocklistItem); + + const mediaRepository = getRepository(Media); + + const mediaItem = await mediaRepository.findOneOrFail({ + where: { + tmdbId: Number(req.params.id), + mediaType: req.query.mediaType as MediaType, + }, + }); + + await mediaRepository.remove(mediaItem); + + return res.status(204).send(); + } catch (e) { + if (e instanceof EntityNotFoundError) { + return next({ + status: 404, + message: e.message, + }); + } + return next({ status: 500, message: e.message }); + } + } +); + blocklistRoutes.delete( '/collection/:id', isAuthenticated([Permission.MANAGE_BLOCKLIST], { @@ -211,45 +261,4 @@ blocklistRoutes.delete( } ); -blocklistRoutes.delete( - '/:id', - isAuthenticated([Permission.MANAGE_BLOCKLIST], { - type: 'or', - }), - async (req, res, next) => { - try { - const blocklistRepository = getRepository(Blocklist); - const mediaRepository = getRepository(Media); - - const blocklistItem = await blocklistRepository.findOneOrFail({ - where: { tmdbId: Number(req.params.id) }, - }); - - await blocklistRepository.remove(blocklistItem); - - try { - const mediaItem = await mediaRepository.findOneOrFail({ - where: { tmdbId: Number(req.params.id) }, - }); - - mediaItem.status = MediaStatus.UNKNOWN; - mediaItem.status4k = MediaStatus.UNKNOWN; - await mediaRepository.save(mediaItem); - } catch { - // Media entity doesn't exist, which is fine - } - - return res.status(204).send(); - } catch (e) { - if (e instanceof EntityNotFoundError) { - return next({ - status: 404, - message: e.message, - }); - } - return next({ status: 500, message: e.message }); - } - } -); - export default blocklistRoutes; From 9bae52dc41d395acd18351c1de901531b5ef7c4c Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 22:49:17 +0100 Subject: [PATCH 10/25] fix(blocklist): update blocklist API endpoints --- seerr-api.yml | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/seerr-api.yml b/seerr-api.yml index 4cb58ac751..afcb9834a4 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -4651,14 +4651,6 @@ paths: example: '1' schema: type: string - - in: query - name: mediaType - required: true - schema: - type: string - enum: - - movie - - tv responses: '204': description: Succesfully removed media item @@ -4798,12 +4790,12 @@ paths: responses: '204': description: Succesfully removed media item - /blacklist/collection/{collectionId}: + /blocklist/collection/{collectionId}: post: - summary: Add collection to blacklist - description: Adds all movies in a collection to the blacklist + summary: Add collection to blocklist + description: Adds all movies in a collection to the blocklist tags: - - blacklist + - blocklist parameters: - in: path name: collectionId @@ -4820,14 +4812,14 @@ paths: type: object responses: '201': - description: Successfully added collection to blacklist + description: Successfully added collection to blocklist '500': - description: Error adding collection to blacklist + description: Error adding collection to blocklist delete: - summary: Remove collection from blacklist - description: Removes all movies in a collection from the blacklist + summary: Remove collection from blocklist + description: Removes all movies in a collection from the blocklist tags: - - blacklist + - blocklist parameters: - in: path name: collectionId @@ -4838,9 +4830,9 @@ paths: type: string responses: '204': - description: Successfully removed collection from blacklist + description: Successfully removed collection from blocklist '500': - description: Error removing collection from blacklist + description: Error removing collection from blocklist /watchlist: post: summary: Add media to watchlist From c0bc846e6815ce9d8803ed920013d1f2291afaa2 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 22:56:07 +0100 Subject: [PATCH 11/25] refactor(blocklist): streamline blocklist permissions check --- src/components/CollectionDetails/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index ec3288aefe..77d09c79a0 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -267,11 +267,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { ); } - const blocklistVisibility = hasPermission( - [Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], - { type: 'or' } - ); - return (
{
- {blocklistVisibility && + {hasPermission([Permission.MANAGE_BLOCKLIST], { type: 'or' }) && (isCollectionBlocklisted ? ( { isEmpty={data.parts.length === 0} items={data.parts .filter((title) => { - if (!blocklistVisibility) + if ( + !hasPermission( + [Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], + { type: 'or' } + ) + ) return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED; return title; }) From b3399312be570717abe7044ceadf8849c3c7ca5c Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:05:03 +0100 Subject: [PATCH 12/25] feat(collection): add revalidation support --- src/components/CollectionDetails/index.tsx | 1 + src/components/TitleCard/index.tsx | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 77d09c79a0..e16a6dfefb 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -493,6 +493,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { userScore={title.voteAverage} year={title.releaseDate} mediaType={title.mediaType} + mutateParent={revalidate} /> ))} /> diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index a6f8a4b5d0..04a661611b 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -204,6 +204,9 @@ const TitleCard = ({ { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.BLOCKLISTED); + if (mutateParent) { + mutateParent(); + } } catch (e) { if (e?.response?.status === 412) { addToast( @@ -275,6 +278,9 @@ const TitleCard = ({ { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.UNKNOWN); + if (mutateParent) { + mutateParent(); + } } else { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', From 8fa89e46f706ae26f0b69662771e024a14033a83 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:08:34 +0100 Subject: [PATCH 13/25] feat(blocklist): add endpoint to blocklist all movies in a collection --- server/routes/blocklist.ts | 70 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index dba50342e1..98ec309ca4 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -160,6 +160,76 @@ 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 blocklistRepository = getRepository(Blocklist); + const mediaRepository = getRepository(Media); + + // Blocklist all movies in the collection + await Promise.all( + collection.parts.map(async (part) => { + const existingBlocklist = await blocklistRepository.findOne({ + where: { tmdbId: part.id }, + }); + + if (existingBlocklist) { + return; + } + + const blocklist = new Blocklist({ + tmdbId: part.id, + mediaType: MediaType.MOVIE, + title: part.title, + user: req.user, + }); + + await blocklistRepository.save(blocklist); + + let media = await mediaRepository.findOne({ + where: { tmdbId: 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], { From e1369052a44a75a5d4a19b85a648f519d10c0492 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:13:15 +0100 Subject: [PATCH 14/25] refactor: remove unused collection blocklist check --- src/components/TitleCard/index.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 04a661611b..860b2f745c 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -91,14 +91,6 @@ const TitleCard = ({ setCurrentStatus(status); }, [status]); - // For collections, check if any movies in the collection are blacklisted - useEffect(() => { - if (mediaType === 'collection' && !status) { - // We could add a check here to determine collection blacklist status - // For now, we'll rely on the status prop being passed from parent components - } - }, [mediaType, status]); - const requestComplete = useCallback((newStatus: MediaStatus) => { setCurrentStatus(newStatus); setShowRequestModal(false); From 175338a845ed9135f3c8c8a51805450141098b42 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:25:21 +0100 Subject: [PATCH 15/25] fix(blocklist): wrong api for collection blocklist --- src/components/TitleCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 860b2f745c..01722dc0ab 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -177,7 +177,7 @@ const TitleCard = ({ if (topNode) { try { if (mediaType === 'collection') { - await axios.post(`/api/v1/blacklist/collection/${id}`); + await axios.post(`/api/v1/blocklist/collection/${id}`); } else { await axios.post('/api/v1/blocklist', { tmdbId: id, From 5066784c9dad5bb3c44dbaf86e31227c3ce5173a Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:26:12 +0100 Subject: [PATCH 16/25] feat: trigger parent mutation on status update --- src/components/TitleCard/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 01722dc0ab..454028b460 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -248,6 +248,9 @@ const TitleCard = ({ { appearance: 'success', autoDismiss: true } ); setCurrentStatus(MediaStatus.UNKNOWN); + if (mutateParent) { + mutateParent(); + } } else { addToast(intl.formatMessage(globalMessages.blocklistError), { appearance: 'error', From e588620d25c8c91e2788af54f9d57750ad1af9a9 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:28:09 +0100 Subject: [PATCH 17/25] fix(blocklist): ensure mediaType is set --- server/routes/blocklist.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index 98ec309ca4..bfc2ce0468 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -180,7 +180,7 @@ blocklistRoutes.post( await Promise.all( collection.parts.map(async (part) => { const existingBlocklist = await blocklistRepository.findOne({ - where: { tmdbId: part.id }, + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, }); if (existingBlocklist) { @@ -197,7 +197,7 @@ blocklistRoutes.post( await blocklistRepository.save(blocklist); let media = await mediaRepository.findOne({ - where: { tmdbId: part.id }, + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, }); if (!media) { @@ -300,14 +300,14 @@ blocklistRoutes.delete( await Promise.all( collection.parts.map(async (part) => { const blocklistItem = await blocklistRepository.findOne({ - where: { tmdbId: part.id }, + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, }); if (blocklistItem) { await blocklistRepository.remove(blocklistItem); const mediaItem = await mediaRepository.findOne({ - where: { tmdbId: part.id }, + where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, }); if (mediaItem) { From e9f268ab42d9bec17520c6886f57b474cf52052a Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Mon, 16 Mar 2026 23:38:35 +0100 Subject: [PATCH 18/25] fix(blocklist): directly remove from repository --- server/routes/blocklist.ts | 4 +--- src/components/TitleCard/index.tsx | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index bfc2ce0468..016eb33172 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -311,9 +311,7 @@ blocklistRoutes.delete( }); if (mediaItem) { - mediaItem.status = MediaStatus.UNKNOWN; - mediaItem.status4k = MediaStatus.UNKNOWN; - await mediaRepository.save(mediaItem); + await mediaRepository.remove(mediaItem); } } }) diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 454028b460..fd81ef7835 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -2,7 +2,6 @@ import Spinner from '@app/assets/spinner.svg'; import BlocklistModal from '@app/components/BlocklistModal'; 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'; From c713ee35d899aac2742e66560d43d0915d4ee7a1 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Mar 2026 19:44:12 +0100 Subject: [PATCH 19/25] refactor: blacklist left-over --- src/components/CollectionDetails/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index e16a6dfefb..9050a3a082 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -110,7 +110,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { setShowBlocklistModal(false); }; - const onClickUnblacklistBtn = async (): Promise => { + const onClickUnblocklistBtn = async (): Promise => { if (!data) return; setIsBlocklistUpdating(true); @@ -394,7 +394,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { buttonType="ghost" className="z-40 mr-2" buttonSize="md" - onClick={onClickUnblacklistBtn} + onClick={onClickUnblocklistBtn} disabled={isBlocklistUpdating} > From 5cbb12bc4e261ffe98db8405b9ec98077c94ba87 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Mar 2026 19:54:22 +0100 Subject: [PATCH 20/25] fix(blocklist): handle duplicate entries during save --- server/routes/blocklist.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index 016eb33172..6362814084 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -187,14 +187,30 @@ blocklistRoutes.post( return; } - const blocklist = new Blocklist({ + let blocklist = new Blocklist({ tmdbId: part.id, mediaType: MediaType.MOVIE, title: part.title, user: req.user, }); - await blocklistRepository.save(blocklist); + 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 = await mediaRepository.findOne({ where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, From 69a8b71ccbec168c822b758d1624f53b61a99c77 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Mar 2026 20:00:15 +0100 Subject: [PATCH 21/25] refactor: reduce database queries --- server/routes/blocklist.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/server/routes/blocklist.ts b/server/routes/blocklist.ts index 6362814084..f22af3ca30 100644 --- a/server/routes/blocklist.ts +++ b/server/routes/blocklist.ts @@ -8,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(); @@ -176,14 +176,30 @@ blocklistRoutes.post( const blocklistRepository = getRepository(Blocklist); const mediaRepository = getRepository(Media); - // Blocklist all movies in the collection - await Promise.all( - collection.parts.map(async (part) => { - const existingBlocklist = await blocklistRepository.findOne({ - where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, - }); + 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(); + } - if (existingBlocklist) { + 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; } @@ -212,10 +228,7 @@ blocklistRoutes.post( blocklist = row; } - let media = await mediaRepository.findOne({ - where: { tmdbId: part.id, mediaType: MediaType.MOVIE }, - }); - + let media = mediaByTmdbId.get(part.id); if (!media) { media = new Media({ tmdbId: part.id, @@ -312,7 +325,6 @@ blocklistRoutes.delete( const blocklistRepository = getRepository(Blocklist); const mediaRepository = getRepository(Media); - // Remove all movies in the collection from blocklist await Promise.all( collection.parts.map(async (part) => { const blocklistItem = await blocklistRepository.findOne({ From 075608c1cf3168357e656b67587d2259b4739f1b Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sun, 22 Mar 2026 20:09:12 +0100 Subject: [PATCH 22/25] feat(collection): add message for partial blocklist removal --- src/components/CollectionDetails/index.tsx | 13 +++++++++++-- src/i18n/locale/en.json | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 9050a3a082..e5de415532 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -34,6 +34,8 @@ 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', }); @@ -386,8 +388,15 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => { content={ blocklistedParts.length === data.parts.length ? intl.formatMessage(globalMessages.removefromBlocklist) - : intl.formatMessage(globalMessages.removefromBlocklist) + - ` (${blocklistedParts.length} movies)` + : intl.formatMessage( + messages.removefromblocklistpartialcount, + { + removeLabel: intl.formatMessage( + globalMessages.removefromBlocklist + ), + count: blocklistedParts.length, + } + ) } >