Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
611a547
feat(blacklist): add support for collections
0xSysR3ll Aug 8, 2025
1f8ef23
fix(blacklist): remove unecessary POST data
0xSysR3ll Aug 8, 2025
0a8ae7b
revert(blacklist): bring back original implementation
0xSysR3ll Aug 8, 2025
75a7624
refactor(blacklist): remove blacklist route implementation
0xSysR3ll Mar 16, 2026
adc12e7
refactor(blacklist): rename blacklist to blocklist
0xSysR3ll Mar 16, 2026
a667ebb
fix(blocklist): remove unused caught error variables
0xSysR3ll Mar 16, 2026
8594e71
feat(blocklist): add partial blocklist status
0xSysR3ll Mar 16, 2026
a5f51b0
refactor: remove blacklist related code
0xSysR3ll Mar 16, 2026
bb9668c
refactor: re-order functions
0xSysR3ll Mar 16, 2026
9bae52d
fix(blocklist): update blocklist API endpoints
0xSysR3ll Mar 16, 2026
c0bc846
refactor(blocklist): streamline blocklist permissions check
0xSysR3ll Mar 16, 2026
b339931
feat(collection): add revalidation support
0xSysR3ll Mar 16, 2026
8fa89e4
feat(blocklist): add endpoint to blocklist all movies in a collection
0xSysR3ll Mar 16, 2026
e136905
refactor: remove unused collection blocklist check
0xSysR3ll Mar 16, 2026
175338a
fix(blocklist): wrong api for collection blocklist
0xSysR3ll Mar 16, 2026
5066784
feat: trigger parent mutation on status update
0xSysR3ll Mar 16, 2026
e588620
fix(blocklist): ensure mediaType is set
0xSysR3ll Mar 16, 2026
e9f268a
fix(blocklist): directly remove from repository
0xSysR3ll Mar 16, 2026
c713ee3
refactor: blacklist left-over
0xSysR3ll Mar 22, 2026
5cbb12b
fix(blocklist): handle duplicate entries during save
0xSysR3ll Mar 22, 2026
69a8b71
refactor: reduce database queries
0xSysR3ll Mar 22, 2026
075608c
feat(collection): add message for partial blocklist removal
0xSysR3ll Mar 22, 2026
929d571
refactor: prefer transaction
0xSysR3ll Mar 22, 2026
1e7a964
refactor: bring back original visibility check
0xSysR3ll Mar 22, 2026
1fa9cd1
revert: removed API docs by mistake
0xSysR3ll Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions seerr-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
159 changes: 156 additions & 3 deletions server/routes/blocklist.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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();
Expand All @@ -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({
Expand Down Expand Up @@ -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], {
Expand Down Expand Up @@ -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 });
}
}
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export default blocklistRoutes;
34 changes: 28 additions & 6 deletions src/components/BlocklistModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -37,7 +49,9 @@ const BlocklistModal = ({
isUpdating,
}: BlocklistModalProps) => {
const intl = useIntl();
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
const [data, setData] = useState<
TvDetails | MovieDetails | Collection | null
>(null);
const [error, setError] = useState(null);

useEffect(() => {
Expand Down Expand Up @@ -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={
Expand Down
Loading
Loading