feat(blacklist): Automatically add media with blacklisted tags to the blacklist (#1306)

* feat(blacklist): add blacktag settings to main settings page

* feat(blacklist): create blacktag logic and infrastructure

* feat(blacklist): add scheduling for blacktags job

* feat(blacklist): create blacktag ui badge for blacklist

* docs(blacklist): document blacktags in using-jellyseerr

* fix(blacklist): batch blacklist and media db removes to avoid expression tree too large error

* feat(blacklist): allow easy import and export of blacktag configuration

* fix(settings): don't copy the API key every time you press enter on the main settings

* fix(blacklist): move filter inline with page title to match all the other pages

* feat(blacklist): allow filtering between manually blacklisted and automatically blacklisted entries

* docs(blacklist): reword blacktag documentation a little

* refactor(blacklist): remove blacktag settings from public settings interfaces

There's no reason for it to be there

* refactor(blacklist): remove unused variable from processResults in blacktagsProcessor

* refactor(blacklist): change all instances of blacktag to blacklistedTag and update doc to match

* docs(blacklist): update general documentation for blacklisted tag settings

* fix(blacklist): update setting use of "blacklisted tag" to match between modals

* perf(blacklist): remove media type constraint from existing blacklist entry query

Doesn't make sense to keep it because tmdbid has a unique constraint on it

* fix(blacklist): remove whitespace line causing prettier to fail in CI

* refactor(blacklist): swap out some != and == for !s and _s

* fix(blacklist): merge back CopyButton changes, disable button when there's nothing to copy

* refactor(blacklist): use axios instead of fetch for blacklisted tag queries

* style(blacklist): use templated axios types and remove redundant try-catches
This commit is contained in:
Ben Beauchamp
2025-04-11 09:48:44 -05:00
committed by GitHub
parent a488f850f3
commit 4a5ac3cc42
21 changed files with 1105 additions and 100 deletions

View File

@@ -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: '<strong>{title}</strong> 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<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
const [currentFilter, setCurrentFilter] = useState<Filter>(Filter.MANUAL);
const router = useRouter();
const intl = useIntl();
@@ -64,9 +76,11 @@ const Blacklist = () => {
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/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 (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-grow-0 sm:flex-row sm:justify-end">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<FunnelIcon className="h-6 w-6" />
</span>
<select
id="filter"
name="filter"
onChange={(e) => {
setCurrentFilter(e.target.value as Filter);
router.push({
pathname: router.pathname,
query: router.query.userId
? { userId: router.query.userId }
: {},
});
}}
value={currentFilter}
className="rounded-r-only"
>
<option value="all">
{intl.formatMessage(globalMessages.all)}
</option>
<option value="manual">
{intl.formatMessage(messages.filterManual)}
</option>
<option value="blacklistedTags">
{intl.formatMessage(messages.filterBlacklistedTags)}
</option>
</select>
</div>
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 md:flex-grow-0">
<span className="inline-flex cursor-default items-center rounded-l-md border border-r-0 border-gray-500 bg-gray-800 px-3 text-sm text-gray-100">
<MagnifyingGlassIcon className="h-6 w-6" />
</span>
<input
type="text"
className="rounded-r-only"
value={searchFilter}
onChange={(e) => searchItem(e)}
/>
</div>
</div>
</div>
@@ -117,6 +164,16 @@ const Blacklist = () => {
<span className="text-2xl text-gray-400">
{intl.formatMessage(globalMessages.noresults)}
</span>
{currentFilter !== Filter.ALL && (
<div className="mt-4">
<Button
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
{intl.formatMessage(messages.showAllBlacklisted)}
</Button>
</div>
)}
</div>
) : (
data.results.map((item: BlacklistItem) => {
@@ -352,7 +409,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
numeric="auto"
/>
),
user: (
user: item.user ? (
<Link href={`/users/${item.user.id}`}>
<span className="group flex items-center truncate">
<CachedImage
@@ -369,6 +426,14 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>
</span>
</Link>
) : item.blacklistedTags ? (
<span className="ml-1">
<BlacklistedTagsBadge data={item} />
</span>
) : (
<span className="ml-1 truncate text-sm font-semibold">
???
</span>
),
})}
</span>

View File

@@ -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 = ({
<div className="flex items-center justify-between">
<div className="mr-6 min-w-0 flex-1 flex-col items-center text-sm leading-5">
<div className="white mb-1 flex flex-nowrap">
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
{data.user ? (
<>
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
</span>
</Link>
</span>
</Link>
</span>
</>
) : data.blacklistedTags ? (
<>
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(messages.blacklistedby)}:&nbsp;
</span>
<BlacklistedTagsBadge data={data} />
</>
) : null}
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">

View File

@@ -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<string>('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<Keyword>(
`/api/v1/keyword/${keywordId}`
);
return data.name;
} catch (err) {
return '';
}
})
).then((keywords) => {
setTagNamesBlacklistedFor(keywords.join(', '));
});
}, [data.blacklistedTags]);
return (
<Tooltip
content={tagNamesBlacklistedFor}
tooltipConfig={{ followCursor: false }}
>
<Badge
badgeType="dark"
className="items-center border border-red-500 !text-red-400"
>
<TagIcon className="mr-1 h-4" />
{intl.formatMessage(messages.blacklistedTagsText)}
</Badge>
</Tooltip>
);
};
export default BlacklistedTagsBadge;

View File

@@ -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<string | undefined>(defaultValue);
const intl = useIntl();
const [selectorValue, setSelectorValue] =
useState<MultiValue<SingleVal> | null>(null);
const update = useCallback(
(value: MultiValue<SingleVal> | 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 (
<>
<ControlledKeywordSelector
value={selectorValue}
onChange={update}
defaultValue={defaultValue}
components={{
DropdownIndicator: undefined,
IndicatorSeparator: undefined,
ClearIndicator: VerifyClearIndicator,
}}
/>
<CopyButton
textToCopy={value ?? ''}
disabled={copyDisabled}
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
tooltipContent={intl.formatMessage(
copyDisabled
? messages.copyBlacklistedTagsEmpty
: messages.copyBlacklistedTagsTip
)}
tooltipConfig={{ followCursor: false }}
/>
<BlacklistedTagsImportButton setSelector={update} />
</>
);
};
type BaseSelectorMultiProps = {
defaultValue?: string;
value: MultiValue<SingleVal> | null;
onChange: (value: MultiValue<SingleVal> | null) => void;
components?: Partial<typeof components>;
};
const ControlledKeywordSelector = ({
defaultValue,
onChange,
components,
value,
}: BaseSelectorMultiProps) => {
const intl = useIntl();
useEffect(() => {
const loadDefaultKeywords = async (): Promise<void> => {
if (!defaultValue) {
return;
}
const keywords = await Promise.all(
defaultValue.split(',').map(async (keywordId) => {
const { data } = await axios.get<Keyword>(
`/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<TmdbKeywordSearchResponse>(
`/api/v1/search/keyword?query=${encodeURIExtraParams(inputValue)}`
);
return data.results.map((result) => ({
label: result.name,
value: result.id,
}));
};
return (
<AsyncSelect
key={`keyword-select-blacklistedTags`}
inputId="data"
isMulti
className="react-select-container"
classNamePrefix="react-select"
noOptionsMessage={({ inputValue }) =>
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<SingleVal>) => void;
};
const BlacklistedTagsImportButton = ({
setSelector,
}: BlacklistedTagsImportButtonProps) => {
const [show, setShow] = useState(false);
const formRef = useRef<HTMLFormElement>(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 (
<>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
okText="Confirm"
onOk={onConfirm}
onCancel={() => setShow(false)}
>
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
</Modal>
</Transition>
<Tooltip
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
tooltipConfig={{ followCursor: false }}
>
<button className="input-action" onClick={onClick} type="button">
<ArrowDownIcon />
</button>
</Tooltip>
</>
);
};
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
const BlacklistedTagImportForm = forwardRef<
Partial<HTMLFormElement>,
BlacklistedTagImportFormProps
>((props, ref) => {
const { setSelector } = props;
const intl = useIntl();
const [formValue, setFormValue] = useState('');
const [errors, setErrors] = useState<string[]>([]);
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<Keyword>(
`/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<SingleVal>[]).map(
(keyword) => keyword.value
)
);
setErrors([]);
return true;
};
const handleSubmit = validate;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="value">
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
</label>
<textarea
id="value"
value={formValue}
onChange={(e) => setFormValue(e.target.value)}
className="h-20"
/>
{errors.length > 0 && (
<div className="error">
{errors.map((error) => (
<div key={error}>{error}</div>
))}
</div>
)}
</div>
</form>
);
});
const VerifyClearIndicator = <
Option,
IsMuti extends boolean,
Group extends GroupBase<Option>
>(
props: ClearIndicatorProps<Option, IsMuti, Group>
) => {
const { clearValue } = props;
const [show, setShow] = useState(false);
const intl = useIntl();
const openForm = useCallback(() => {
setShow(true);
}, [setShow]);
const openFormKey = useCallback(
(event: React.KeyboardEvent) => {
if (show) return;
if (event.key === 'Enter' || event.key === 'Space') {
setShow(true);
}
},
[setShow, show]
);
const acceptForm = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Enter') {
event.stopPropagation();
event.preventDefault();
clearValue();
}
},
[clearValue]
);
useEffect(() => {
if (show) {
window.addEventListener('keydown', acceptForm);
}
return () => window.removeEventListener('keydown', acceptForm);
}, [show, acceptForm]);
return (
<>
<button
type="button"
onClick={openForm}
onKeyDown={openFormKey}
className="react-select__indicator react-select__clear-indicator css-1xc3v61-indicatorContainer cursor-pointer"
>
<components.CrossIcon />
</button>
<Transition
as="div"
enter="transition-opacity duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={show}
>
<Modal
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
okText={intl.formatMessage(messages.yes)}
cancelText={intl.formatMessage(messages.no)}
onOk={clearValue}
onCancel={() => setShow(false)}
>
<form />{' '}
{/* Form prevents accidentally saving settings when pressing enter */}
</Modal>
</Transition>
</>
);
};
export default BlacklistedTagsSelector;

View File

@@ -1,40 +1,54 @@
import defineMessages from '@app/utils/defineMessages';
import Tooltip from '@app/components/Common/Tooltip';
import { ClipboardDocumentIcon } from '@heroicons/react/24/solid';
import { useEffect } from 'react';
import { useIntl } from 'react-intl';
import React, { useEffect } from 'react';
import type { Config } from 'react-popper-tooltip';
import { useToasts } from 'react-toast-notifications';
import useClipboard from 'react-use-clipboard';
const messages = defineMessages('components.Settings', {
copied: 'Copied API key to clipboard.',
});
type CopyButtonProps = {
textToCopy: string;
disabled?: boolean;
toastMessage?: string;
const CopyButton = ({ textToCopy }: { textToCopy: string }) => {
const intl = useIntl();
tooltipContent?: React.ReactNode;
tooltipConfig?: Partial<Config>;
};
const CopyButton = ({
textToCopy,
disabled,
toastMessage,
tooltipContent,
tooltipConfig,
}: CopyButtonProps) => {
const [isCopied, setCopied] = useClipboard(textToCopy, {
successDuration: 1000,
});
const { addToast } = useToasts();
useEffect(() => {
if (isCopied) {
addToast(intl.formatMessage(messages.copied), {
if (isCopied && toastMessage) {
addToast(toastMessage, {
appearance: 'info',
autoDismiss: true,
});
}
}, [isCopied, addToast, intl]);
}, [isCopied, addToast, toastMessage]);
return (
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
>
<ClipboardDocumentIcon />
</button>
<Tooltip content={tooltipContent} tooltipConfig={tooltipConfig}>
<button
onClick={(e) => {
e.preventDefault();
setCopied();
}}
className="input-action"
type="button"
disabled={disabled}
>
<ClipboardDocumentIcon />
</button>
</Tooltip>
);
};

View File

@@ -68,11 +68,14 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'download-sync': 'Download Sync',
'download-sync-reset': 'Download Sync Reset',
'image-cache-cleanup': 'Image Cache Cleanup',
'process-blacklisted-tags': 'Process Blacklisted Tags',
editJobSchedule: 'Modify Job',
jobScheduleEditSaved: 'Job edited successfully!',
jobScheduleEditFailed: 'Something went wrong while saving the job.',
editJobScheduleCurrent: 'Current Frequency',
editJobSchedulePrompt: 'New Frequency',
editJobScheduleSelectorDays:
'Every {jobScheduleDays, plural, one {day} other {{jobScheduleDays} days}}',
editJobScheduleSelectorHours:
'Every {jobScheduleHours, plural, one {hour} other {{jobScheduleHours} hours}}',
editJobScheduleSelectorMinutes:
@@ -92,7 +95,7 @@ interface Job {
id: JobId;
name: string;
type: 'process' | 'command';
interval: 'seconds' | 'minutes' | 'hours' | 'fixed';
interval: 'seconds' | 'minutes' | 'hours' | 'days' | 'fixed';
cronSchedule: string;
nextExecutionTime: string;
running: boolean;
@@ -101,13 +104,20 @@ interface Job {
type JobModalState = {
isOpen?: boolean;
job?: Job;
scheduleDays: number;
scheduleHours: number;
scheduleMinutes: number;
scheduleSeconds: number;
};
type JobModalAction =
| { type: 'set'; hours?: number; minutes?: number; seconds?: number }
| {
type: 'set';
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}
| {
type: 'close';
}
@@ -128,6 +138,7 @@ const jobModalReducer = (
return {
isOpen: true,
job: action.job,
scheduleDays: 1,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
@@ -136,6 +147,7 @@ const jobModalReducer = (
case 'set':
return {
...state,
scheduleDays: action.days ?? state.scheduleDays,
scheduleHours: action.hours ?? state.scheduleHours,
scheduleMinutes: action.minutes ?? state.scheduleMinutes,
scheduleSeconds: action.seconds ?? state.scheduleSeconds,
@@ -164,6 +176,7 @@ const SettingsJobs = () => {
const [jobModalState, dispatch] = useReducer(jobModalReducer, {
isOpen: false,
scheduleDays: 1,
scheduleHours: 1,
scheduleMinutes: 5,
scheduleSeconds: 30,
@@ -239,6 +252,9 @@ const SettingsJobs = () => {
jobScheduleCron[1] = `*/${jobModalState.scheduleMinutes}`;
} else if (jobModalState.job?.interval === 'hours') {
jobScheduleCron[2] = `*/${jobModalState.scheduleHours}`;
} else if (jobModalState.job?.interval === 'days') {
jobScheduleCron[2] = '1';
jobScheduleCron[3] = `*/${jobModalState.scheduleDays}`;
} else {
// jobs with interval: fixed should not be editable
throw new Error();
@@ -367,6 +383,29 @@ const SettingsJobs = () => {
</option>
))}
</select>
) : jobModalState.job?.interval === 'days' ? (
<select
name="jobScheduleDays"
className="inline"
value={jobModalState.scheduleDays}
onChange={(e) =>
dispatch({
type: 'set',
days: Number(e.target.value),
})
}
>
{[1, 2, 3, 4, 5, 6, 7, 10, 14, 21].map((v) => (
<option value={v} key={`jobScheduleDays-${v}`}>
{intl.formatMessage(
messages.editJobScheduleSelectorDays,
{
jobScheduleDays: v,
}
)}
</option>
))}
</select>
) : (
<select
name="jobScheduleHours"

View File

@@ -1,3 +1,4 @@
import BlacklistedTagsSelector from '@app/components/BlacklistedTagsSelector';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
@@ -29,12 +30,19 @@ const messages = defineMessages('components.Settings.SettingsMain', {
generalsettingsDescription:
'Configure global and default settings for Jellyseerr.',
apikey: 'API Key',
apikeyCopied: 'Copied API key to clipboard.',
applicationTitle: 'Application Title',
applicationurl: 'Application URL',
discoverRegion: 'Discover Region',
discoverRegionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
originallanguageTip: 'Filter content by original language',
blacklistedTags: 'Blacklist Content with Tags',
blacklistedTagsTip:
'Automatically add content with tags to the blacklist using the "Process Blacklisted Tags" job',
blacklistedTagsLimit: 'Limit Content Blacklisted per Tag',
blacklistedTagsLimitTip:
'The "Process Blacklisted Tags" job will blacklist this many pages into each sort. Larger numbers will create a more accurate blacklist, but use more space.',
streamingRegion: 'Streaming Region',
streamingRegionTip: 'Show streaming sites by regional availability',
toastApiKeySuccess: 'New API key generated successfully!',
@@ -81,6 +89,17 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
(value) => !value || !value.endsWith('/')
),
blacklistedTagsLimit: Yup.number()
.test(
'positive',
'Number must be greater than 0.',
(value) => (value ?? 0) >= 0
)
.test(
'lte-250',
'Number must be less than or equal to 250.',
(value) => (value ?? 0) <= 250
),
});
const regenerate = async () => {
@@ -130,6 +149,8 @@ const SettingsMain = () => {
discoverRegion: data?.discoverRegion,
originalLanguage: data?.originalLanguage,
streamingRegion: data?.streamingRegion || 'US',
blacklistedTags: data?.blacklistedTags,
blacklistedTagsLimit: data?.blacklistedTagsLimit || 50,
partialRequestsEnabled: data?.partialRequestsEnabled,
enableSpecialEpisodes: data?.enableSpecialEpisodes,
cacheImages: data?.cacheImages,
@@ -146,6 +167,8 @@ const SettingsMain = () => {
discoverRegion: values.discoverRegion,
streamingRegion: values.streamingRegion,
originalLanguage: values.originalLanguage,
blacklistedTags: values.blacklistedTags,
blacklistedTagsLimit: values.blacklistedTagsLimit,
partialRequestsEnabled: values.partialRequestsEnabled,
enableSpecialEpisodes: values.enableSpecialEpisodes,
cacheImages: values.cacheImages,
@@ -201,6 +224,9 @@ const SettingsMain = () => {
/>
<CopyButton
textToCopy={data?.apiKey ?? ''}
toastMessage={intl.formatMessage(
messages.apikeyCopied
)}
key={data?.apiKey}
/>
<button
@@ -209,6 +235,7 @@ const SettingsMain = () => {
regenerate();
}}
className="input-action"
type="button"
>
<ArrowPathIcon />
</button>
@@ -352,6 +379,49 @@ const SettingsMain = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="blacklistedTags" className="text-label">
<span>{intl.formatMessage(messages.blacklistedTags)}</span>
<span className="label-tip">
{intl.formatMessage(messages.blacklistedTagsTip)}
</span>
</label>
<div className="form-input-area">
<div className="form-input-field relative z-10">
<BlacklistedTagsSelector
defaultValue={values.blacklistedTags}
/>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="blacklistedTagsLimit" className="text-label">
<span className="mr-2">
{intl.formatMessage(messages.blacklistedTagsLimit)}
</span>
<SettingsBadge badgeType="advanced" />
<span className="label-tip">
{intl.formatMessage(messages.blacklistedTagsLimitTip)}
</span>
</label>
<div className="form-input-area">
<Field
id="blacklistedTagsLimit"
name="blacklistedTagsLimit"
type="text"
inputMode="numeric"
className="short"
placeholder={50}
/>
{errors.blacklistedTagsLimit &&
touched.blacklistedTagsLimit &&
typeof errors.blacklistedTagsLimit === 'string' && (
<div className="error">
{errors.blacklistedTagsLimit}
</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="hideAvailable" className="checkbox-label">
<span className="mr-2">