diff --git a/seerr-api.yml b/seerr-api.yml index b7fca1edd..ae1fdc254 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -8238,6 +8238,70 @@ paths: application/json: schema: $ref: '#/components/schemas/OverrideRule' + /overrideRule/advancedRequest: + post: + summary: Advanced override rule request + description: Processes an advanced override rule request. + tags: + - overriderule + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - mediaType + - tmdbId + properties: + mediaType: + type: string + tmdbId: + type: number + example: 1 + is4k: + type: boolean + example: false + requestId: + type: number + nullable: true + requestUser: + type: number + nullable: true + tags: + type: array + items: + type: number + nullable: true + serviceId: + type: number + nullable: true + responses: + '200': + description: Advanced override rule request processed + content: + application/json: + schema: + type: object + properties: + rootFolder: + type: string + nullable: true + profileId: + type: number + nullable: true + tags: + type: array + items: + type: number + nullable: true + '403': + description: User does not have permission to modify the request user + '404': + description: User, media or request not found + '500': + description: Unable to evaluate override rules + security: - cookieAuth: [] - apiKey: [] diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index c08b5cbbc..4f17c81f6 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -1,15 +1,13 @@ import TheMovieDb from '@server/api/themoviedb'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; -import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus, MediaType, } from '@server/constants/media'; import { getRepository } from '@server/datasource'; -import OverrideRule from '@server/entity/OverrideRule'; import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces'; import notificationManager, { Notification } from '@server/lib/notifications'; +import overrideRules from '@server/lib/overrideRules'; import { Permission } from '@server/lib/permissions'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -256,125 +254,30 @@ export class MediaRequest { let tags = requestBody.tags; if (useOverrides) { - const defaultRadarrId = requestBody.is4k - ? settings.radarr.find((r) => r.is4k && r.isDefault)?.id - : settings.radarr.find((r) => !r.is4k && r.isDefault)?.id; - const defaultSonarrId = requestBody.is4k - ? settings.sonarr.find((s) => s.is4k && s.isDefault)?.id - : settings.sonarr.find((s) => !s.is4k && s.isDefault)?.id; - - const [defaultServiceIdField, defaultServiceId] = - requestBody.mediaType === MediaType.MOVIE - ? (['radarrServiceId', defaultRadarrId] as const) - : (['sonarrServiceId', defaultSonarrId] as const); - - const overrideRuleRepository = getRepository(OverrideRule); - const overrideRules = - defaultServiceId === undefined - ? [] - : await overrideRuleRepository.find({ - where: { [defaultServiceIdField]: defaultServiceId }, - }); - - const appliedOverrideRules = overrideRules.filter((rule) => { - const hasAnimeKeyword = - 'results' in tmdbMedia.keywords && - tmdbMedia.keywords.results.some( - (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ); - - // Skip override rules if the media is an anime TV show as anime TV - // is handled by default and override rules do not explicitly include - // the anime keyword - if ( - requestBody.mediaType === MediaType.TV && - hasAnimeKeyword && - (!rule.keywords || - !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) - ) { - return false; - } - - if ( - rule.users && - !rule.users - .split(',') - .some((userId) => Number(userId) === requestUser.id) - ) { - return false; - } - if ( - rule.genre && - !rule.genre - .split(',') - .some((genreId) => - tmdbMedia.genres - .map((genre) => genre.id) - .includes(Number(genreId)) - ) - ) { - return false; - } - if ( - rule.language && - !rule.language - .split('|') - .some((languageId) => languageId === tmdbMedia.original_language) - ) { - return false; - } - if ( - rule.keywords && - !rule.keywords.split(',').some((keywordId) => { - let keywordList: TmdbKeyword[] = []; - - if ('keywords' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.keywords; - } else if ('results' in tmdbMedia.keywords) { - keywordList = tmdbMedia.keywords.results; - } - - return keywordList - .map((keyword: TmdbKeyword) => keyword.id) - .includes(Number(keywordId)); - }) - ) { - return false; - } - return true; + const overrideRulesResult = await overrideRules({ + mediaType: requestBody.mediaType, + is4k: requestBody.is4k || false, + tmdbMedia, + requestUser, + tags, }); - - // hacky way to prioritize rules - // TODO: make this better - const prioritizedRule = appliedOverrideRules.sort((a, b) => { - const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; - - const aSpecificity = keys.filter((key) => a[key] !== null).length; - const bSpecificity = keys.filter((key) => b[key] !== null).length; - - // Take the rule with the most specific condition first - return bSpecificity - aSpecificity; - })[0]; - - if (prioritizedRule) { - if (prioritizedRule.rootFolder) { - rootFolder = prioritizedRule.rootFolder; - } - if (prioritizedRule.profileId) { - profileId = prioritizedRule.profileId; - } - if (prioritizedRule.tags) { - tags = [ - ...new Set([ - ...(tags || []), - ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), - ]), - ]; - } - + if (overrideRulesResult.rootFolder) { + rootFolder = overrideRulesResult.rootFolder; + } + if (overrideRulesResult.profileId) { + profileId = overrideRulesResult.profileId; + } + if (overrideRulesResult.tags) { + tags = overrideRulesResult.tags; + } + if ( + overrideRulesResult.rootFolder || + overrideRulesResult.profileId || + overrideRulesResult.tags + ) { logger.debug('Override rule applied.', { - label: 'Media Request', - overrides: prioritizedRule, + label: 'Override Rules', + overrides: overrideRulesResult, }); } } diff --git a/server/lib/overrideRules.ts b/server/lib/overrideRules.ts new file mode 100644 index 000000000..3d31876ca --- /dev/null +++ b/server/lib/overrideRules.ts @@ -0,0 +1,166 @@ +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { + TmdbKeyword, + TmdbMovieDetails, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import OverrideRule from '@server/entity/OverrideRule'; +import type { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; + +export type OverrideRulesResult = { + rootFolder: string | null; + profileId: number | null; + tags: number[] | null; +}; + +async function overrideRules({ + mediaType, + is4k, + tmdbMedia, + requestUser, + tags = null, + serviceId, +}: { + mediaType: MediaType; + is4k: boolean; + tmdbMedia: TmdbMovieDetails | TmdbTvDetails; + requestUser: User; + tags?: number[] | null; + serviceId?: number; +}): Promise { + const settings = getSettings(); + + let rootFolder: string | null = null; + let profileId: number | null = null; + + const serviceIdField = + mediaType === MediaType.MOVIE ? 'radarrServiceId' : 'sonarrServiceId'; + if (serviceId === undefined) { + const defaultRadarrId = is4k + ? settings.radarr.find((r) => r.is4k && r.isDefault)?.id + : settings.radarr.find((r) => !r.is4k && r.isDefault)?.id; + const defaultSonarrId = is4k + ? settings.sonarr.find((s) => s.is4k && s.isDefault)?.id + : settings.sonarr.find((s) => !s.is4k && s.isDefault)?.id; + serviceId = + mediaType === MediaType.MOVIE ? defaultRadarrId : defaultSonarrId; + } + + const overrideRuleRepository = getRepository(OverrideRule); + const rules = + serviceId === undefined + ? [] + : await overrideRuleRepository.find({ + where: { [serviceIdField]: serviceId }, + }); + + const appliedOverrideRules = rules.filter((rule) => { + const hasAnimeKeyword = + 'results' in tmdbMedia.keywords && + tmdbMedia.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ); + + // Skip override rules if the media is an anime TV show as anime TV + // is handled by default and override rules do not explicitly include + // the anime keyword + if ( + mediaType === MediaType.TV && + hasAnimeKeyword && + (!rule.keywords || + !rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID)) + ) { + return false; + } + + if ( + rule.users && + !rule.users.split(',').some((userId) => Number(userId) === requestUser.id) + ) { + return false; + } + if ( + rule.genre && + !rule.genre + .split(',') + .some((genreId) => + tmdbMedia.genres.map((genre) => genre.id).includes(Number(genreId)) + ) + ) { + return false; + } + if ( + rule.language && + !rule.language + .split('|') + .some((languageId) => languageId === tmdbMedia.original_language) + ) { + return false; + } + if ( + rule.keywords && + !rule.keywords.split(',').some((keywordId) => { + let keywordList: TmdbKeyword[] = []; + + if ('keywords' in tmdbMedia.keywords) { + keywordList = tmdbMedia.keywords.keywords; + } else if ('results' in tmdbMedia.keywords) { + keywordList = tmdbMedia.keywords.results; + } + + return keywordList + .map((keyword: TmdbKeyword) => keyword.id) + .includes(Number(keywordId)); + }) + ) { + return false; + } + return true; + }); + + // hacky way to prioritize rules + // TODO: make this better + const hasSpecificOverrideValue = ( + rule: OverrideRule, + key: keyof OverrideRule + ) => { + const value = rule[key]; + if (value == null) return false; + return typeof value !== 'string' || value.trim() !== ''; + }; + const prioritizedRule = appliedOverrideRules.sort((a, b) => { + const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords']; + const aSpecificity = keys.filter((key) => + hasSpecificOverrideValue(a, key) + ).length; + const bSpecificity = keys.filter((key) => + hasSpecificOverrideValue(b, key) + ).length; + // Take the rule with the most specific condition first + return bSpecificity - aSpecificity; + })[0]; + + if (prioritizedRule) { + if (prioritizedRule.rootFolder) { + rootFolder = prioritizedRule.rootFolder; + } + if (prioritizedRule.profileId) { + profileId = prioritizedRule.profileId; + } + if (prioritizedRule.tags) { + tags = [ + ...new Set([ + ...(tags || []), + ...prioritizedRule.tags.split(',').map((tag) => Number(tag)), + ]), + ]; + } + } + + return { rootFolder, profileId, tags }; +} + +export default overrideRules; diff --git a/server/routes/index.ts b/server/routes/index.ts index 270104ecf..d97a69c5e 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -178,11 +178,7 @@ router.use('/service', isAuthenticated(), serviceRoutes); router.use('/issue', isAuthenticated(), issueRoutes); router.use('/issueComment', isAuthenticated(), issueCommentRoutes); router.use('/auth', authRoutes); -router.use( - '/overrideRule', - isAuthenticated(Permission.ADMIN), - overrideRuleRoutes -); +router.use('/overrideRule', isAuthenticated(), overrideRuleRoutes); router.get('/regions', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); diff --git a/server/routes/overrideRule.ts b/server/routes/overrideRule.ts index 912a68aae..e33369673 100644 --- a/server/routes/overrideRule.ts +++ b/server/routes/overrideRule.ts @@ -1,7 +1,19 @@ +import TheMovieDb from '@server/api/themoviedb'; +import type { + TmdbMovieDetails, + TmdbTvDetails, +} from '@server/api/themoviedb/interfaces'; +import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; +import { MediaRequest } from '@server/entity/MediaRequest'; import OverrideRule from '@server/entity/OverrideRule'; +import { User } from '@server/entity/User'; import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces'; +import overrideRules, { + type OverrideRulesResult, +} from '@server/lib/overrideRules'; import { Permission } from '@server/lib/permissions'; +import logger from '@server/logger'; import { isAuthenticated } from '@server/middleware/auth'; import { Router } from 'express'; @@ -61,6 +73,127 @@ overrideRuleRoutes.post< } }); +overrideRuleRoutes.post< + Record, + OverrideRulesResult, + { + mediaType: MediaType; + is4k: boolean; + tmdbId: number; + requestUser: number; + requestId: number | null; + tags?: number[] | null; + serviceId?: number; + } +>( + '/advancedRequest', + isAuthenticated([Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], { + type: 'or', + }), + async (req, res, next) => { + try { + const tmdb = new TheMovieDb(); + let tmdbMedia: TmdbMovieDetails | TmdbTvDetails; + try { + tmdbMedia = + req.body.mediaType === MediaType.MOVIE + ? await tmdb.getMovie({ movieId: req.body.tmdbId }) + : await tmdb.getTvShow({ tvId: req.body.tmdbId }); + } catch { + return next({ status: 404, message: 'Media not found' }); + } + + const userRepository = getRepository(User); + const requestRepository = getRepository(MediaRequest); + + let ruleUser: User | null = req.user || null; + + if (req.body.requestId) { + const request = await requestRepository.findOne({ + where: { + id: Number(req.body.requestId), + }, + }); + + if (!request) { + return next({ status: 404, message: 'Request not found.' }); + } + + if ( + (request.requestedBy.id !== req.user?.id || + (req.body.mediaType !== MediaType.TV && + !req.user?.hasPermission(Permission.REQUEST_ADVANCED))) && + !req.user?.hasPermission(Permission.MANAGE_REQUESTS) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify this request.', + }); + } + + ruleUser = request.requestedBy; + + if ( + req.body.requestUser && + req.body.requestUser !== request.requestedBy.id && + !req.user?.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.requestUser) { + ruleUser = await userRepository.findOneOrFail({ + where: { id: req.body.requestUser }, + }); + } + } else if (req.body.requestUser !== req.user?.id) { + if ( + req.body.requestUser && + !req.user?.hasPermission([ + Permission.MANAGE_USERS, + Permission.MANAGE_REQUESTS, + ]) + ) { + return next({ + status: 403, + message: 'You do not have permission to modify the request user.', + }); + } else if (req.body.requestUser) { + ruleUser = await userRepository.findOneOrFail({ + where: { id: req.body.requestUser }, + }); + } + } + + if (!ruleUser) { + return next({ status: 404, message: 'User not found.' }); + } + + const overrideRulesResult: OverrideRulesResult = await overrideRules({ + mediaType: req.body.mediaType, + is4k: req.body.is4k, + tmdbMedia, + requestUser: ruleUser, + tags: req.body.tags, + serviceId: req.body.serviceId, + }); + + res.status(200).json(overrideRulesResult); + } catch (e) { + logger.error('Something went wrong evaluating override rules', { + label: 'API', + errorMessage: e.message, + tmdbId: req.body.tmdbId, + }); + next({ status: 500, message: 'Unable to evaluate override rules.' }); + } + } +); + overrideRuleRoutes.put< { ruleId: string }, OverrideRule, diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index d19e70dd7..8dd6f08d7 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -2,6 +2,7 @@ import CachedImage from '@app/components/Common/CachedImage'; import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner'; import SlideCheckbox from '@app/components/Common/SlideCheckbox'; +import useToasts from '@app/hooks/useToasts'; import type { User } from '@app/hooks/useUser'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -21,7 +22,9 @@ import type { ServiceCommonServerWithDetails, } from '@server/interfaces/api/serviceInterfaces'; import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces'; +import type { OverrideRulesResult } from '@server/lib/overrideRules'; import { hasPermission } from '@server/lib/permissions'; +import axios from 'axios'; import { isEqual } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; @@ -63,24 +66,29 @@ export type RequestOverrides = { interface AdvancedRequesterProps { type: 'movie' | 'tv'; + tmdbId?: number; is4k: boolean; isAnime?: boolean; defaultOverrides?: RequestOverrides; requestUser?: User; + requestId?: number; quota?: { movie: { limit?: number }; tv: { limit?: number } }; onChange: (overrides: RequestOverrides) => void; } const AdvancedRequester = ({ type, + tmdbId, is4k = false, isAnime = false, defaultOverrides, requestUser, + requestId, quota, onChange, }: AdvancedRequesterProps) => { const intl = useIntl(); + const { addToast } = useToasts(); const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { data, error } = useSWR( `/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`, @@ -330,6 +338,68 @@ const AdvancedRequester = ({ isIgnoreQuotaVisible, ]); + useEffect(() => { + let cancelled = false; + (async () => { + if (tmdbId && serverData?.server.id === selectedServer) { + try { + const { data: override } = await axios.post( + '/api/v1/overrideRule/advancedRequest', + { + mediaType: type, + is4k, + requestUser: + selectedUser?.id ?? requestUser?.id ?? currentUser?.id, + tmdbId, + tags: selectedTags.length > 0 ? selectedTags : undefined, + serviceId: selectedServer ?? undefined, + requestId: requestId ?? undefined, + } + ); + if (cancelled) { + return; + } + if (!defaultOverrides?.folder && override.rootFolder) { + setSelectedFolder(override.rootFolder); + } + if (!defaultOverrides?.profile && override.profileId) { + setSelectedProfile(override.profileId); + } + if ( + !defaultOverrides?.tags && + override.tags && + !isEqual(override.tags, selectedTags) + ) { + setSelectedTags(override.tags); + } + } catch { + if (cancelled) { + return; + } + addToast(intl.formatMessage(globalMessages.error), { + appearance: 'error', + autoDismiss: true, + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [ + tmdbId, + type, + is4k, + serverData?.server.id, + selectedServer, + selectedUserId, + requestUser?.id, + currentUser?.id, + defaultOverrides?.folder, + defaultOverrides?.profile, + defaultOverrides?.tags, + ]); + if (!data && !error) { return (
diff --git a/src/components/RequestModal/CollectionRequestModal.tsx b/src/components/RequestModal/CollectionRequestModal.tsx index f704b09c0..a0374e4a6 100644 --- a/src/components/RequestModal/CollectionRequestModal.tsx +++ b/src/components/RequestModal/CollectionRequestModal.tsx @@ -517,8 +517,10 @@ const CollectionRequestModal = ({
- {(hasPermission(Permission.REQUEST_ADVANCED) || - hasPermission(Permission.MANAGE_REQUESTS)) && ( + {hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) && ( )} - {(hasPermission(Permission.REQUEST_ADVANCED) || - hasPermission(Permission.MANAGE_REQUESTS)) && ( + {hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) && ( - {(hasPermission(Permission.REQUEST_ADVANCED) || - hasPermission(Permission.MANAGE_REQUESTS)) && ( + {hasPermission( + [Permission.REQUEST_ADVANCED, Permission.MANAGE_REQUESTS], + { type: 'or' } + ) && ( keyword.id === ANIME_KEYWORD_ID @@ -725,6 +728,7 @@ const TvRequestModal = ({ quota={quota} onChange={(overrides) => setRequestOverrides(overrides)} requestUser={editRequest?.requestedBy} + requestId={editRequest?.id} defaultOverrides={ editRequest ? {