feat(blocklist): add support for collections (#1841)
This commit is contained in:
@@ -4798,6 +4798,49 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'204':
|
'204':
|
||||||
description: Succesfully removed media item
|
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:
|
/watchlist:
|
||||||
post:
|
post:
|
||||||
summary: Add media to watchlist
|
summary: Add media to watchlist
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MediaType } from '@server/constants/media';
|
import TheMovieDb from '@server/api/themoviedb';
|
||||||
import { getRepository } from '@server/datasource';
|
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||||
|
import dataSource, { getRepository } from '@server/datasource';
|
||||||
import { Blocklist } from '@server/entity/Blocklist';
|
import { Blocklist } from '@server/entity/Blocklist';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
|
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
|
||||||
@@ -7,7 +8,7 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
import { EntityNotFoundError, In, QueryFailedError } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const blocklistRoutes = Router();
|
const blocklistRoutes = Router();
|
||||||
@@ -17,6 +18,7 @@ export const blocklistAdd = z.object({
|
|||||||
mediaType: z.nativeEnum(MediaType),
|
mediaType: z.nativeEnum(MediaType),
|
||||||
title: z.coerce.string().optional(),
|
title: z.coerce.string().optional(),
|
||||||
user: z.coerce.number(),
|
user: z.coerce.number(),
|
||||||
|
blocklistedTags: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blocklistGet = z.object({
|
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(
|
blocklistRoutes.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
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;
|
export default blocklistRoutes;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import Modal from '@app/components/Common/Modal';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
|
|
||||||
|
import type { Collection } from '@server/models/Collection';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -21,8 +23,18 @@ const messages = defineMessages('component.BlocklistModal', {
|
|||||||
blocklisting: 'Blocklisting',
|
blocklisting: 'Blocklisting',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCollection = (
|
||||||
|
data: MovieDetails | TvDetails | Collection | null
|
||||||
|
): data is Collection => {
|
||||||
|
return (
|
||||||
|
data !== null &&
|
||||||
|
data !== undefined &&
|
||||||
|
(data as Collection).parts !== undefined
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
movie: MovieDetails | TvDetails | null
|
movie: MovieDetails | TvDetails | Collection | null
|
||||||
): movie is MovieDetails => {
|
): movie is MovieDetails => {
|
||||||
if (!movie) return false;
|
if (!movie) return false;
|
||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
@@ -37,7 +49,9 @@ const BlocklistModal = ({
|
|||||||
isUpdating,
|
isUpdating,
|
||||||
}: BlocklistModalProps) => {
|
}: BlocklistModalProps) => {
|
||||||
const intl = useIntl();
|
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);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,11 +82,19 @@ const BlocklistModal = ({
|
|||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
backgroundClickable
|
backgroundClickable
|
||||||
title={`${intl.formatMessage(globalMessages.blocklist)} ${
|
title={`${intl.formatMessage(globalMessages.blocklist)} ${
|
||||||
isMovie(data)
|
type === 'collection'
|
||||||
|
? intl.formatMessage(globalMessages.collection)
|
||||||
|
: isMovie(data)
|
||||||
? intl.formatMessage(globalMessages.movie)
|
? intl.formatMessage(globalMessages.movie)
|
||||||
: intl.formatMessage(globalMessages.tvshow)
|
: intl.formatMessage(globalMessages.tvshow)
|
||||||
}`}
|
}`}
|
||||||
subTitle={`${isMovie(data) ? data.title : data?.name}`}
|
subTitle={`${
|
||||||
|
isCollection(data)
|
||||||
|
? data.name
|
||||||
|
: isMovie(data)
|
||||||
|
? data.title
|
||||||
|
: data?.name
|
||||||
|
}`}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onOk={onComplete}
|
onOk={onComplete}
|
||||||
okText={
|
okText={
|
||||||
|
|||||||
@@ -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 ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import RequestModal from '@app/components/RequestModal';
|
import RequestModal from '@app/components/RequestModal';
|
||||||
import Slider from '@app/components/Slider';
|
import Slider from '@app/components/Slider';
|
||||||
import StatusBadge from '@app/components/StatusBadge';
|
import StatusBadge from '@app/components/StatusBadge';
|
||||||
@@ -12,19 +15,27 @@ import globalMessages from '@app/i18n/globalMessages';
|
|||||||
import ErrorPage from '@app/pages/_error';
|
import ErrorPage from '@app/pages/_error';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { refreshIntervalHelper } from '@app/utils/refreshIntervalHelper';
|
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 { MediaStatus } from '@server/constants/media';
|
||||||
import type { Collection } from '@server/models/Collection';
|
import type { Collection } from '@server/models/Collection';
|
||||||
|
import axios from 'axios';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.CollectionDetails', {
|
const messages = defineMessages('components.CollectionDetails', {
|
||||||
overview: 'Overview',
|
overview: 'Overview',
|
||||||
numberofmovies: '{count} Movies',
|
numberofmovies: '{count} Movies',
|
||||||
|
removefromblocklistpartialcount:
|
||||||
|
'{removeLabel} ({count, plural, one {# movie} other {# movies}})',
|
||||||
requestcollection: 'Request Collection',
|
requestcollection: 'Request Collection',
|
||||||
requestcollection4k: 'Request Collection in 4K',
|
requestcollection4k: 'Request Collection in 4K',
|
||||||
});
|
});
|
||||||
@@ -40,6 +51,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
const [requestModal, setRequestModal] = useState(false);
|
const [requestModal, setRequestModal] = useState(false);
|
||||||
const [is4k, setIs4k] = 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 returnCollectionDownloadItems = (data: Collection | undefined) => {
|
||||||
const [downloadStatus, downloadStatus4k] = [
|
const [downloadStatus, downloadStatus4k] = [
|
||||||
@@ -70,6 +84,63 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
const { data: genres } =
|
const { data: genres } =
|
||||||
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
useSWR<{ id: number; name: string }[]>(`/api/v1/genres/movie`);
|
||||||
|
|
||||||
|
const onClickHideItemBtn = async (): Promise<void> => {
|
||||||
|
setIsBlocklistUpdating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`/api/v1/blocklist/collection/${data?.id}`);
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(globalMessages.blocklistSuccess, {
|
||||||
|
title: data?.name,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
} catch {
|
||||||
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBlocklistUpdating(false);
|
||||||
|
setShowBlocklistModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickUnblocklistBtn = async (): Promise<void> => {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
setIsBlocklistUpdating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/blocklist/collection/${data.id}`);
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
||||||
|
title: data.name,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ appearance: 'success', autoDismiss: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
} catch {
|
||||||
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBlocklistUpdating(false);
|
||||||
|
};
|
||||||
|
|
||||||
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
const [downloadStatus, downloadStatus4k] = useMemo(() => {
|
||||||
const downloadItems = returnCollectionDownloadItems(data);
|
const downloadItems = returnCollectionDownloadItems(data);
|
||||||
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
return [downloadItems.downloadStatus, downloadItems.downloadStatus4k];
|
||||||
@@ -97,7 +168,17 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
let collectionStatus = MediaStatus.UNKNOWN;
|
let collectionStatus = MediaStatus.UNKNOWN;
|
||||||
let collectionStatus4k = 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(
|
data.parts.every(
|
||||||
(part) =>
|
(part) =>
|
||||||
part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE
|
part.mediaInfo && part.mediaInfo.status === MediaStatus.AVAILABLE
|
||||||
@@ -152,6 +233,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
part.mediaInfo.status4k === MediaStatus.UNKNOWN
|
||||||
).length > 0;
|
).length > 0;
|
||||||
|
|
||||||
|
const blocklistVisibility = hasPermission(
|
||||||
|
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
||||||
|
{ type: 'or' }
|
||||||
|
);
|
||||||
|
|
||||||
const collectionAttributes: React.ReactNode[] = [];
|
const collectionAttributes: React.ReactNode[] = [];
|
||||||
|
|
||||||
collectionAttributes.push(
|
collectionAttributes.push(
|
||||||
@@ -188,11 +274,6 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocklistVisibility = hasPermission(
|
|
||||||
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
|
||||||
{ type: 'or' }
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="media-page"
|
className="media-page"
|
||||||
@@ -231,6 +312,15 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
}}
|
}}
|
||||||
onCancel={() => setRequestModal(false)}
|
onCancel={() => setRequestModal(false)}
|
||||||
/>
|
/>
|
||||||
|
<BlocklistModal
|
||||||
|
tmdbId={data.id}
|
||||||
|
type="collection"
|
||||||
|
show={showBlocklistModal}
|
||||||
|
onCancel={() => setShowBlocklistModal(false)}
|
||||||
|
onComplete={onClickHideItemBtn}
|
||||||
|
isUpdating={isBlocklistUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
<CachedImage
|
<CachedImage
|
||||||
@@ -254,6 +344,11 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
status={collectionStatus}
|
status={collectionStatus}
|
||||||
downloadItem={downloadStatus}
|
downloadItem={downloadStatus}
|
||||||
title={titles}
|
title={titles}
|
||||||
|
statusLabelOverride={
|
||||||
|
isCollectionPartiallyBlocklisted
|
||||||
|
? intl.formatMessage(globalMessages.partiallyblocklisted)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
inProgress={data.parts.some(
|
inProgress={data.parts.some(
|
||||||
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
(part) => (part.mediaInfo?.downloadStatus ?? []).length > 0
|
||||||
)}
|
)}
|
||||||
@@ -292,6 +387,48 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-actions">
|
<div className="media-actions">
|
||||||
|
{hasPermission([Permission.MANAGE_BLOCKLIST], { type: 'or' }) &&
|
||||||
|
(isCollectionBlocklisted ? (
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
blocklistedParts.length === data.parts.length
|
||||||
|
? intl.formatMessage(globalMessages.removefromBlocklist)
|
||||||
|
: intl.formatMessage(
|
||||||
|
messages.removefromblocklistpartialcount,
|
||||||
|
{
|
||||||
|
removeLabel: intl.formatMessage(
|
||||||
|
globalMessages.removefromBlocklist
|
||||||
|
),
|
||||||
|
count: blocklistedParts.length,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize="md"
|
||||||
|
onClick={onClickUnblocklistBtn}
|
||||||
|
disabled={isBlocklistUpdating}
|
||||||
|
>
|
||||||
|
<EyeIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
content={intl.formatMessage(globalMessages.addToBlocklist)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
className="z-40 mr-2"
|
||||||
|
buttonSize="md"
|
||||||
|
onClick={() => setShowBlocklistModal(true)}
|
||||||
|
disabled={isBlocklistUpdating}
|
||||||
|
>
|
||||||
|
<EyeSlashIcon />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
{(hasRequestable || hasRequestable4k) && (
|
{(hasRequestable || hasRequestable4k) && (
|
||||||
<ButtonWithDropdown
|
<ButtonWithDropdown
|
||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
@@ -349,8 +486,9 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
isEmpty={data.parts.length === 0}
|
isEmpty={data.parts.length === 0}
|
||||||
items={data.parts
|
items={data.parts
|
||||||
.filter((title) => {
|
.filter((title) => {
|
||||||
if (!blocklistVisibility)
|
if (!blocklistVisibility) {
|
||||||
return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED;
|
return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED;
|
||||||
|
}
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
.map((title) => (
|
.map((title) => (
|
||||||
@@ -365,6 +503,7 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
userScore={title.voteAverage}
|
userScore={title.voteAverage}
|
||||||
year={title.releaseDate}
|
year={title.releaseDate}
|
||||||
mediaType={title.mediaType}
|
mediaType={title.mediaType}
|
||||||
|
mutateParent={revalidate}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface StatusBadgeProps {
|
|||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
mediaType?: 'movie' | 'tv';
|
mediaType?: 'movie' | 'tv';
|
||||||
title?: string | string[];
|
title?: string | string[];
|
||||||
|
statusLabelOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StatusBadge = ({
|
const StatusBadge = ({
|
||||||
@@ -43,6 +44,7 @@ const StatusBadge = ({
|
|||||||
tmdbId,
|
tmdbId,
|
||||||
mediaType,
|
mediaType,
|
||||||
title,
|
title,
|
||||||
|
statusLabelOverride,
|
||||||
}: StatusBadgeProps) => {
|
}: StatusBadgeProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
@@ -364,7 +366,9 @@ const StatusBadge = ({
|
|||||||
<Tooltip content={mediaLinkDescription}>
|
<Tooltip content={mediaLinkDescription}>
|
||||||
<Badge badgeType="danger" href={mediaLink}>
|
<Badge badgeType="danger" href={mediaLink}>
|
||||||
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||||
status: intl.formatMessage(globalMessages.blocklisted),
|
status:
|
||||||
|
statusLabelOverride ??
|
||||||
|
intl.formatMessage(globalMessages.blocklisted),
|
||||||
})}
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -175,12 +175,16 @@ const TitleCard = ({
|
|||||||
|
|
||||||
if (topNode) {
|
if (topNode) {
|
||||||
try {
|
try {
|
||||||
|
if (mediaType === 'collection') {
|
||||||
|
await axios.post(`/api/v1/blocklist/collection/${id}`);
|
||||||
|
} else {
|
||||||
await axios.post('/api/v1/blocklist', {
|
await axios.post('/api/v1/blocklist', {
|
||||||
tmdbId: id,
|
tmdbId: id,
|
||||||
mediaType,
|
mediaType,
|
||||||
title,
|
title,
|
||||||
user: user?.id,
|
user: user?.id,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.blocklistSuccess, {
|
{intl.formatMessage(globalMessages.blocklistSuccess, {
|
||||||
@@ -191,6 +195,9 @@ const TitleCard = ({
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
setCurrentStatus(MediaStatus.BLOCKLISTED);
|
setCurrentStatus(MediaStatus.BLOCKLISTED);
|
||||||
|
if (mutateParent) {
|
||||||
|
mutateParent();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.response?.status === 412) {
|
if (e?.response?.status === 412) {
|
||||||
addToast(
|
addToast(
|
||||||
@@ -225,6 +232,31 @@ const TitleCard = ({
|
|||||||
const topNode = cardRef.current;
|
const topNode = cardRef.current;
|
||||||
|
|
||||||
if (topNode) {
|
if (topNode) {
|
||||||
|
try {
|
||||||
|
if (mediaType === 'collection') {
|
||||||
|
const res = await axios.delete(`/api/v1/blocklist/collection/${id}`);
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
addToast(
|
||||||
|
<span>
|
||||||
|
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
||||||
|
title,
|
||||||
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
|
})}
|
||||||
|
</span>,
|
||||||
|
{ 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(
|
const res = await axios.delete(
|
||||||
`/api/v1/blocklist/${id}?mediaType=${mediaType}`
|
`/api/v1/blocklist/${id}?mediaType=${mediaType}`
|
||||||
);
|
);
|
||||||
@@ -240,12 +272,22 @@ const TitleCard = ({
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
setCurrentStatus(MediaStatus.UNKNOWN);
|
setCurrentStatus(MediaStatus.UNKNOWN);
|
||||||
|
if (mutateParent) {
|
||||||
|
mutateParent();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
|
appearance: 'error',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ const globalMessages = defineMessages('i18n', {
|
|||||||
resolved: 'Resolved',
|
resolved: 'Resolved',
|
||||||
blocklist: 'Blocklist',
|
blocklist: 'Blocklist',
|
||||||
blocklisted: 'Blocklisted',
|
blocklisted: 'Blocklisted',
|
||||||
|
partiallyblocklisted: 'Partially Blocklisted',
|
||||||
blocklistSuccess: '<strong>{title}</strong> was successfully blocklisted.',
|
blocklistSuccess: '<strong>{title}</strong> was successfully blocklisted.',
|
||||||
blocklistError: 'Something went wrong. Please try again.',
|
blocklistError: 'Something went wrong. Please try again.',
|
||||||
blocklistDuplicateError:
|
blocklistDuplicateError:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"components.Blocklist.showAllBlocklisted": "Show All Blocklisted Media",
|
"components.Blocklist.showAllBlocklisted": "Show All Blocklisted Media",
|
||||||
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
"components.CollectionDetails.numberofmovies": "{count} Movies",
|
||||||
"components.CollectionDetails.overview": "Overview",
|
"components.CollectionDetails.overview": "Overview",
|
||||||
|
"components.CollectionDetails.removefromblocklistpartialcount": "{removeLabel} ({count, plural, one {# movie} other {# movies}})",
|
||||||
"components.CollectionDetails.requestcollection": "Request Collection",
|
"components.CollectionDetails.requestcollection": "Request Collection",
|
||||||
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
|
"components.CollectionDetails.requestcollection4k": "Request Collection in 4K",
|
||||||
"components.Discover.CreateSlider.addSlider": "Add Slider",
|
"components.Discover.CreateSlider.addSlider": "Add Slider",
|
||||||
@@ -1601,6 +1602,7 @@
|
|||||||
"i18n.notrequested": "Not Requested",
|
"i18n.notrequested": "Not Requested",
|
||||||
"i18n.open": "Open",
|
"i18n.open": "Open",
|
||||||
"i18n.partiallyavailable": "Partially Available",
|
"i18n.partiallyavailable": "Partially Available",
|
||||||
|
"i18n.partiallyblocklisted": "Partially Blocklisted",
|
||||||
"i18n.pending": "Pending",
|
"i18n.pending": "Pending",
|
||||||
"i18n.previous": "Previous",
|
"i18n.previous": "Previous",
|
||||||
"i18n.processing": "Processing",
|
"i18n.processing": "Processing",
|
||||||
|
|||||||
Reference in New Issue
Block a user