diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 7bcb53241..3cf2e1d98 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -19,6 +19,8 @@ "discoverRegion": "", "streamingRegion": "", "originalLanguage": "", + "blacklistedTags": "", + "blacklistedTagsLimit": 50, "trustProxy": false, "mediaServerType": 1, "partialRequestsEnabled": true, diff --git a/docs/using-jellyseerr/settings/general.md b/docs/using-jellyseerr/settings/general.md index 08601d2a7..e006a43fd 100644 --- a/docs/using-jellyseerr/settings/general.md +++ b/docs/using-jellyseerr/settings/general.md @@ -62,6 +62,14 @@ Set the default display language for Jellyseerr. Users can override this setting These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings. +## Blacklist Content with Tags and Limit Content Blacklisted per Tag + +These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs. + +The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage. + +Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings. + ## Hide Available Media When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages. diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index b427bcf87..cb8e2a9f5 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -4239,6 +4239,12 @@ paths: type: string nullable: true example: dune + - in: query + name: filter + schema: + type: string + enum: [all, manual, blacklistedTags] + default: manual responses: '200': description: Blacklisted items returned diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index 6b6c86ec1..21ca42206 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -38,23 +38,26 @@ interface SingleSearchOptions extends SearchOptions { year?: number; } -export type SortOptions = - | 'popularity.asc' - | 'popularity.desc' - | 'release_date.asc' - | 'release_date.desc' - | 'revenue.asc' - | 'revenue.desc' - | 'primary_release_date.asc' - | 'primary_release_date.desc' - | 'original_title.asc' - | 'original_title.desc' - | 'vote_average.asc' - | 'vote_average.desc' - | 'vote_count.asc' - | 'vote_count.desc' - | 'first_air_date.asc' - | 'first_air_date.desc'; +export const SortOptionsIterable = [ + 'popularity.desc', + 'popularity.asc', + 'release_date.desc', + 'release_date.asc', + 'revenue.desc', + 'revenue.asc', + 'primary_release_date.desc', + 'primary_release_date.asc', + 'original_title.asc', + 'original_title.desc', + 'vote_average.desc', + 'vote_average.asc', + 'vote_count.desc', + 'vote_count.asc', + 'first_air_date.desc', + 'first_air_date.asc', +] as const; + +export type SortOptions = (typeof SortOptionsIterable)[number]; interface DiscoverMovieOptions { page?: number; diff --git a/server/entity/Blacklist.ts b/server/entity/Blacklist.ts index 4ce3a86e2..a6bac9d9e 100644 --- a/server/entity/Blacklist.ts +++ b/server/entity/Blacklist.ts @@ -1,8 +1,9 @@ import { MediaStatus, type MediaType } from '@server/constants/media'; -import { getRepository } from '@server/datasource'; +import dataSource from '@server/datasource'; import Media from '@server/entity/Media'; import { User } from '@server/entity/User'; import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces'; +import type { EntityManager } from 'typeorm'; import { Column, CreateDateColumn, @@ -35,7 +36,7 @@ export class Blacklist implements BlacklistItem { @ManyToOne(() => User, (user) => user.id, { eager: true, }) - user: User; + user?: User; @OneToOne(() => Media, (media) => media.blacklist, { onDelete: 'CASCADE', @@ -43,6 +44,9 @@ export class Blacklist implements BlacklistItem { @JoinColumn() public media: Media; + @Column({ nullable: true, type: 'varchar' }) + public blacklistedTags?: string; + @CreateDateColumn() public createdAt: Date; @@ -50,27 +54,32 @@ export class Blacklist implements BlacklistItem { Object.assign(this, init); } - public static async addToBlacklist({ - blacklistRequest, - }: { - blacklistRequest: { - mediaType: MediaType; - title?: ZodOptional['_output']; - tmdbId: ZodNumber['_output']; - }; - }): Promise { + public static async addToBlacklist( + { + blacklistRequest, + }: { + blacklistRequest: { + mediaType: MediaType; + title?: ZodOptional['_output']; + tmdbId: ZodNumber['_output']; + blacklistedTags?: string; + }; + }, + entityManager?: EntityManager + ): Promise { + const em = entityManager ?? dataSource; const blacklist = new this({ ...blacklistRequest, }); - const mediaRepository = getRepository(Media); + const mediaRepository = em.getRepository(Media); let media = await mediaRepository.findOne({ where: { tmdbId: blacklistRequest.tmdbId, }, }); - const blacklistRepository = getRepository(this); + const blacklistRepository = em.getRepository(this); await blacklistRepository.save(blacklist); diff --git a/server/interfaces/api/blacklistInterfaces.ts b/server/interfaces/api/blacklistInterfaces.ts index 99e56585c..0cf4646ea 100644 --- a/server/interfaces/api/blacklistInterfaces.ts +++ b/server/interfaces/api/blacklistInterfaces.ts @@ -6,7 +6,8 @@ export interface BlacklistItem { mediaType: 'movie' | 'tv'; title?: string; createdAt?: Date; - user: User; + user?: User; + blacklistedTags?: string; } export interface BlacklistResultsResponse extends PaginatedResponse { diff --git a/server/job/blacklistedTagsProcessor.ts b/server/job/blacklistedTagsProcessor.ts new file mode 100644 index 000000000..eab46a1ee --- /dev/null +++ b/server/job/blacklistedTagsProcessor.ts @@ -0,0 +1,184 @@ +import type { SortOptions } from '@server/api/themoviedb'; +import { SortOptionsIterable } from '@server/api/themoviedb'; +import type { + TmdbSearchMovieResponse, + TmdbSearchTvResponse, +} from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; +import dataSource from '@server/datasource'; +import { Blacklist } from '@server/entity/Blacklist'; +import Media from '@server/entity/Media'; +import type { + RunnableScanner, + StatusBase, +} from '@server/lib/scanners/baseScanner'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { createTmdbWithRegionLanguage } from '@server/routes/discover'; +import type { EntityManager } from 'typeorm'; + +const TMDB_API_DELAY_MS = 250; +class AbortTransaction extends Error {} + +class BlacklistedTagProcessor implements RunnableScanner { + private running = false; + private progress = 0; + private total = 0; + + public async run() { + this.running = true; + + try { + await dataSource.transaction(async (em) => { + await this.cleanBlacklist(em); + await this.createBlacklistEntries(em); + }); + } catch (err) { + if (err instanceof AbortTransaction) { + logger.info('Aborting job: Process Blacklisted Tags', { + label: 'Jobs', + }); + } else { + throw err; + } + } finally { + this.reset(); + } + } + + public status(): StatusBase { + return { + running: this.running, + progress: this.progress, + total: this.total, + }; + } + + public cancel() { + this.running = false; + this.progress = 0; + this.total = 0; + } + + private reset() { + this.cancel(); + } + + private async createBlacklistEntries(em: EntityManager) { + const tmdb = createTmdbWithRegionLanguage(); + + const settings = getSettings(); + const blacklistedTags = settings.main.blacklistedTags; + const blacklistedTagsArr = blacklistedTags.split(','); + + const pageLimit = settings.main.blacklistedTagsLimit; + + if (blacklistedTags.length === 0) { + return; + } + + // The maximum number of queries we're expected to execute + this.total = + 2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length; + + for (const type of [MediaType.MOVIE, MediaType.TV]) { + const getDiscover = + type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv; + + // Iterate for each tag + for (const tag of blacklistedTagsArr) { + let queryMax = pageLimit * SortOptionsIterable.length; + let fixedSortMode = false; // Set to true when the page limit allows for getting every page of tag + + for (let query = 0; query < queryMax; query++) { + const page: number = fixedSortMode + ? query + 1 + : (query % pageLimit) + 1; + const sortBy: SortOptions | undefined = fixedSortMode + ? undefined + : SortOptionsIterable[query % SortOptionsIterable.length]; + + if (!this.running) { + throw new AbortTransaction(); + } + + const response = await getDiscover({ + page, + sortBy, + keywords: tag, + }); + await this.processResults(response, tag, type, em); + await new Promise((res) => setTimeout(res, TMDB_API_DELAY_MS)); + + this.progress++; + if (page === 1 && response.total_pages <= queryMax) { + // We will finish the tag with less queries than expected, move progress accordingly + this.progress += queryMax - response.total_pages; + fixedSortMode = true; + queryMax = response.total_pages; + } + } + } + } + } + + private async processResults( + response: TmdbSearchMovieResponse | TmdbSearchTvResponse, + keywordId: string, + mediaType: MediaType, + em: EntityManager + ) { + const blacklistRepository = em.getRepository(Blacklist); + + for (const entry of response.results) { + const blacklistEntry = await blacklistRepository.findOne({ + where: { tmdbId: entry.id }, + }); + + if (blacklistEntry) { + // Don't mark manual blacklists with tags + // If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist + if ( + blacklistEntry.blacklistedTags && + !blacklistEntry.blacklistedTags.includes(`,${keywordId},`) + ) { + await blacklistRepository.update(blacklistEntry.id, { + blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`, + }); + } + } else { + // Media wasn't previously blacklisted, add it to the blacklist + await Blacklist.addToBlacklist( + { + blacklistRequest: { + mediaType, + title: 'title' in entry ? entry.title : entry.name, + tmdbId: entry.id, + blacklistedTags: `,${keywordId},`, + }, + }, + em + ); + } + } + } + + private async cleanBlacklist(em: EntityManager) { + // Remove blacklist and media entries blacklisted by tags + const mediaRepository = em.getRepository(Media); + const mediaToRemove = await mediaRepository + .createQueryBuilder('media') + .innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId') + .where(`blist.blacklistedTags IS NOT NULL`) + .getMany(); + + // Batch removes so the query doesn't get too large + for (let i = 0; i < mediaToRemove.length; i += 500) { + await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading + } + } +} + +const blacklistedTagsProcessor = new BlacklistedTagProcessor(); + +export default blacklistedTagsProcessor; diff --git a/server/job/schedule.ts b/server/job/schedule.ts index df0cd9174..c740dbaec 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,4 +1,5 @@ import { MediaServerType } from '@server/constants/server'; +import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor'; import availabilitySync from '@server/lib/availabilitySync'; import downloadTracker from '@server/lib/downloadtracker'; import ImageProxy from '@server/lib/imageproxy'; @@ -21,7 +22,7 @@ interface ScheduledJob { job: schedule.Job; name: string; type: 'process' | 'command'; - interval: 'seconds' | 'minutes' | 'hours' | 'fixed'; + interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed'; cronSchedule: string; running?: () => boolean; cancelFn?: () => void; @@ -237,5 +238,21 @@ export const startJobs = (): void => { }), }); + scheduledJobs.push({ + id: 'process-blacklisted-tags', + name: 'Process Blacklisted Tags', + type: 'process', + interval: 'days', + cronSchedule: jobs['process-blacklisted-tags'].schedule, + job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => { + logger.info('Starting scheduled job: Process Blacklisted Tags', { + label: 'Jobs', + }); + blacklistedTagsProcessor.run(); + }), + running: () => blacklistedTagsProcessor.status().running, + cancelFn: () => blacklistedTagsProcessor.cancel(), + }); + logger.info('Scheduled jobs loaded', { label: 'Jobs' }); }; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index a4738d659..86cdf81c3 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -128,6 +128,8 @@ export interface MainSettings { discoverRegion: string; streamingRegion: string; originalLanguage: string; + blacklistedTags: string; + blacklistedTagsLimit: number; mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; @@ -302,7 +304,8 @@ export type JobId = | 'jellyfin-recently-added-scan' | 'jellyfin-full-scan' | 'image-cache-cleanup' - | 'availability-sync'; + | 'availability-sync' + | 'process-blacklisted-tags'; export interface AllSettings { clientId: string; @@ -349,6 +352,8 @@ class Settings { discoverRegion: '', streamingRegion: '', originalLanguage: '', + blacklistedTags: '', + blacklistedTagsLimit: 50, mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, @@ -505,6 +510,9 @@ class Settings { 'image-cache-cleanup': { schedule: '0 0 5 * * *', }, + 'process-blacklisted-tags': { + schedule: '0 30 1 */7 * *', + }, }, network: { csrfProtection: false, diff --git a/server/migration/postgres/1737320080282-AddBlacklistTagsColumn.ts b/server/migration/postgres/1737320080282-AddBlacklistTagsColumn.ts new file mode 100644 index 000000000..d6a717eeb --- /dev/null +++ b/server/migration/postgres/1737320080282-AddBlacklistTagsColumn.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface { + name = 'AddBlacklistTagsColumn1737320080282'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blacklist" ADD blacklistedTags character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blacklist" DROP COLUMN blacklistedTags` + ); + } +} diff --git a/server/migration/sqlite/1737320080282-AddBlacklistTagsColumn.ts b/server/migration/sqlite/1737320080282-AddBlacklistTagsColumn.ts new file mode 100644 index 000000000..4f38807f6 --- /dev/null +++ b/server/migration/sqlite/1737320080282-AddBlacklistTagsColumn.ts @@ -0,0 +1,34 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlacklistTagsColumn1737320080282 implements MigrationInterface { + name = 'AddBlacklistTagsColumn1737320080282'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blacklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blacklist"("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"` + ); + await queryRunner.query(`DROP TABLE "blacklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blacklist" RENAME TO "temporary_blacklist"` + ); + await queryRunner.query( + `CREATE TABLE "blacklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"))` + ); + await queryRunner.query( + `INSERT INTO "blacklist"("id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "createdAt", "userId", "mediaId" FROM "temporary_blacklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blacklist"`); + } +} diff --git a/server/routes/blacklist.ts b/server/routes/blacklist.ts index bb2dafe88..e0540c489 100644 --- a/server/routes/blacklist.ts +++ b/server/routes/blacklist.ts @@ -19,39 +19,54 @@ export const blacklistAdd = z.object({ 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 pageSize = req.query.take ? Number(req.query.take) : 25; - const skip = req.query.skip ? Number(req.query.skip) : 0; - const search = (req.query.search as string) ?? ''; + const { take, skip, search, filter } = blacklistGet.parse(req.query); try { let query = getRepository(Blacklist) .createQueryBuilder('blacklist') - .leftJoinAndSelect('blacklist.user', 'user'); + .leftJoinAndSelect('blacklist.user', 'user') + .where('1 = 1'); // Allow use of andWhere later - if (search.length > 0) { - query = query.where('blacklist.title like :title', { + 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(pageSize) + .take(take) .skip(skip) .getManyAndCount(); return res.status(200).json({ pageInfo: { - pages: Math.ceil(itemsCount / pageSize), - pageSize, + pages: Math.ceil(itemsCount / take), + pageSize: take, results: itemsCount, - page: Math.ceil(skip / pageSize) + 1, + page: Math.ceil(skip / take) + 1, }, results: blacklistedItems, } as BlacklistResultsResponse); diff --git a/src/components/Blacklist/index.tsx b/src/components/Blacklist/index.tsx index 5c83465ff..2713544f7 100644 --- a/src/components/Blacklist/index.tsx +++ b/src/components/Blacklist/index.tsx @@ -1,3 +1,4 @@ +import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -14,6 +15,7 @@ import defineMessages from '@app/utils/defineMessages'; import { ChevronLeftIcon, ChevronRightIcon, + FunnelIcon, MagnifyingGlassIcon, TrashIcon, } from '@heroicons/react/24/solid'; @@ -42,8 +44,17 @@ const messages = defineMessages('components.Blacklist', { blacklistdate: 'date', blacklistedby: '{date} by {user}', blacklistNotFoundError: '{title} is not blacklisted.', + filterManual: 'Manual', + filterBlacklistedTags: 'Blacklisted Tags', + showAllBlacklisted: 'Show All Blacklisted Media', }); +enum Filter { + ALL = 'all', + MANUAL = 'manual', + BLACKLISTEDTAGS = 'blacklistedTags', +} + const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { return (movie as MovieDetails).title !== undefined; }; @@ -52,6 +63,7 @@ const Blacklist = () => { const [currentPageSize, setCurrentPageSize] = useState(10); const [searchFilter, debouncedSearchFilter, setSearchFilter] = useDebouncedState(''); + const [currentFilter, setCurrentFilter] = useState(Filter.MANUAL); const router = useRouter(); const intl = useIntl(); @@ -64,9 +76,11 @@ const Blacklist = () => { error, mutate: revalidate, } = useSWR( - `/api/v1/blacklist/?take=${currentPageSize} - &skip=${pageIndex * currentPageSize} - ${debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''}`, + `/api/v1/blacklist/?take=${currentPageSize}&skip=${ + pageIndex * currentPageSize + }&filter=${currentFilter}${ + debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : '' + }`, { refreshInterval: 0, revalidateOnFocus: false, @@ -94,19 +108,52 @@ const Blacklist = () => { return ( <> -
{intl.formatMessage(globalMessages.blacklist)}
+
+
{intl.formatMessage(globalMessages.blacklist)}
-
-
- - - - searchItem(e)} - /> +
+
+ + + + +
+ +
+ + + + searchItem(e)} + /> +
@@ -117,6 +164,16 @@ const Blacklist = () => { {intl.formatMessage(globalMessages.noresults)} + {currentFilter !== Filter.ALL && ( +
+ +
+ )}
) : ( data.results.map((item: BlacklistItem) => { @@ -352,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => { numeric="auto" /> ), - user: ( + user: item.user ? ( { + ) : item.blacklistedTags ? ( + + + + ) : ( + + ??? + ), })} diff --git a/src/components/BlacklistBlock/index.tsx b/src/components/BlacklistBlock/index.tsx index 6980a02ef..1e8f1fb68 100644 --- a/src/components/BlacklistBlock/index.tsx +++ b/src/components/BlacklistBlock/index.tsx @@ -1,3 +1,4 @@ +import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge'; import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; @@ -77,22 +78,33 @@ const BlacklistBlock = ({
- - - - - - - {data.user.displayName} + {data.user ? ( + <> + + + + + + + {data.user.displayName} + + - - + + ) : data.blacklistedTags ? ( + <> + + {intl.formatMessage(messages.blacklistedby)}:  + + + + ) : null}
diff --git a/src/components/BlacklistedTagsBadge/index.tsx b/src/components/BlacklistedTagsBadge/index.tsx new file mode 100644 index 000000000..eb1c9a475 --- /dev/null +++ b/src/components/BlacklistedTagsBadge/index.tsx @@ -0,0 +1,62 @@ +import Badge from '@app/components/Common/Badge'; +import Tooltip from '@app/components/Common/Tooltip'; +import defineMessages from '@app/utils/defineMessages'; +import { TagIcon } from '@heroicons/react/20/solid'; +import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces'; +import type { Keyword } from '@server/models/common'; +import axios from 'axios'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +const messages = defineMessages('components.Settings', { + blacklistedTagsText: 'Blacklisted Tags', +}); + +interface BlacklistedTagsBadgeProps { + data: BlacklistItem; +} + +const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => { + const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] = + useState('Loading...'); + const intl = useIntl(); + + useEffect(() => { + if (!data.blacklistedTags) { + return; + } + + const keywordIds = data.blacklistedTags.slice(1, -1).split(','); + Promise.all( + keywordIds.map(async (keywordId) => { + try { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return data.name; + } catch (err) { + return ''; + } + }) + ).then((keywords) => { + setTagNamesBlacklistedFor(keywords.join(', ')); + }); + }, [data.blacklistedTags]); + + return ( + + + + {intl.formatMessage(messages.blacklistedTagsText)} + + + ); +}; + +export default BlacklistedTagsBadge; diff --git a/src/components/BlacklistedTagsSelector/index.tsx b/src/components/BlacklistedTagsSelector/index.tsx new file mode 100644 index 000000000..b83691efc --- /dev/null +++ b/src/components/BlacklistedTagsSelector/index.tsx @@ -0,0 +1,402 @@ +import Modal from '@app/components/Common/Modal'; +import Tooltip from '@app/components/Common/Tooltip'; +import CopyButton from '@app/components/Settings/CopyButton'; +import { encodeURIExtraParams } from '@app/hooks/useDiscover'; +import defineMessages from '@app/utils/defineMessages'; +import { Transition } from '@headlessui/react'; +import { ArrowDownIcon } from '@heroicons/react/24/solid'; +import type { TmdbKeywordSearchResponse } from '@server/api/themoviedb/interfaces'; +import type { Keyword } from '@server/models/common'; +import axios from 'axios'; +import { useFormikContext } from 'formik'; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { ClearIndicatorProps, GroupBase, MultiValue } from 'react-select'; +import { components } from 'react-select'; +import AsyncSelect from 'react-select/async'; + +const messages = defineMessages('components.Settings', { + copyBlacklistedTags: 'Copied blacklisted tags to clipboard.', + copyBlacklistedTagsTip: 'Copy blacklisted tag configuration', + copyBlacklistedTagsEmpty: 'Nothing to copy', + importBlacklistedTagsTip: 'Import blacklisted tag configuration', + clearBlacklistedTagsConfirm: + 'Are you sure you want to clear the blacklisted tags?', + yes: 'Yes', + no: 'No', + searchKeywords: 'Search keywords…', + starttyping: 'Starting typing to search.', + nooptions: 'No results.', + blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration', + blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.', + valueRequired: 'You must provide a value.', + noSpecialCharacters: + 'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.', + invalidKeyword: '{keywordId} is not a TMDB keyword.', +}); + +type SingleVal = { + label: string; + value: number; +}; + +type BlacklistedTagsSelectorProps = { + defaultValue?: string; +}; + +const BlacklistedTagsSelector = ({ + defaultValue, +}: BlacklistedTagsSelectorProps) => { + const { setFieldValue } = useFormikContext(); + const [value, setValue] = useState(defaultValue); + const intl = useIntl(); + const [selectorValue, setSelectorValue] = + useState | null>(null); + + const update = useCallback( + (value: MultiValue | null) => { + const strVal = value?.map((v) => v.value).join(','); + setSelectorValue(value); + setValue(strVal); + setFieldValue('blacklistedTags', strVal); + }, + [setSelectorValue, setValue, setFieldValue] + ); + + const copyDisabled = value === null || value?.length === 0; + + return ( + <> + + + + + + ); +}; + +type BaseSelectorMultiProps = { + defaultValue?: string; + value: MultiValue | null; + onChange: (value: MultiValue | null) => void; + components?: Partial; +}; + +const ControlledKeywordSelector = ({ + defaultValue, + onChange, + components, + value, +}: BaseSelectorMultiProps) => { + const intl = useIntl(); + + useEffect(() => { + const loadDefaultKeywords = async (): Promise => { + if (!defaultValue) { + return; + } + + const keywords = await Promise.all( + defaultValue.split(',').map(async (keywordId) => { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return data; + }) + ); + + onChange( + keywords.map((keyword) => ({ + label: keyword.name, + value: keyword.id, + })) + ); + }; + + loadDefaultKeywords(); + }, [defaultValue, onChange]); + + const loadKeywordOptions = async (inputValue: string) => { + const { data } = await axios.get( + `/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}` + ); + + return data.results.map((result) => ({ + label: result.name, + value: result.id, + })); + }; + + return ( + + inputValue === '' + ? intl.formatMessage(messages.starttyping) + : intl.formatMessage(messages.nooptions) + } + value={value} + loadOptions={loadKeywordOptions} + placeholder={intl.formatMessage(messages.searchKeywords)} + onChange={onChange} + components={components} + /> + ); +}; + +type BlacklistedTagsImportButtonProps = { + setSelector: (value: MultiValue) => void; +}; + +const BlacklistedTagsImportButton = ({ + setSelector, +}: BlacklistedTagsImportButtonProps) => { + const [show, setShow] = useState(false); + const formRef = useRef(null); + const intl = useIntl(); + + const onConfirm = useCallback(async () => { + if (formRef.current) { + if (await formRef.current.submitForm()) { + setShow(false); + } + } + }, []); + + const onClick = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setShow(true); + }, []); + + return ( + <> + + setShow(false)} + > + + + + + + + + + ); +}; + +type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps; + +const BlacklistedTagImportForm = forwardRef< + Partial, + BlacklistedTagImportFormProps +>((props, ref) => { + const { setSelector } = props; + const intl = useIntl(); + const [formValue, setFormValue] = useState(''); + const [errors, setErrors] = useState([]); + + useImperativeHandle(ref, () => ({ + submitForm: handleSubmit, + formValue, + })); + + const validate = async () => { + if (formValue.length === 0) { + setErrors([intl.formatMessage(messages.valueRequired)]); + return false; + } + + if (!/^(?:\d+,)*\d+$/.test(formValue)) { + setErrors([intl.formatMessage(messages.noSpecialCharacters)]); + return false; + } + + const keywords = await Promise.allSettled( + formValue.split(',').map(async (keywordId) => { + try { + const { data } = await axios.get( + `/api/v1/keyword/${keywordId}` + ); + return { + label: data.name, + value: data.id, + }; + } catch (err) { + throw intl.formatMessage(messages.invalidKeyword, { keywordId }); + } + }) + ); + + const failures = keywords.filter( + (res) => res.status === 'rejected' + ) as PromiseRejectedResult[]; + if (failures.length > 0) { + setErrors(failures.map((failure) => `${failure.reason}`)); + return false; + } + + setSelector( + (keywords as PromiseFulfilledResult[]).map( + (keyword) => keyword.value + ) + ); + + setErrors([]); + return true; + }; + + const handleSubmit = validate; + + return ( +
+
+ +