1
0
Fork 0
mirror of https://github.com/morpheus65535/bazarr synced 2024-12-21 23:32:31 +00:00

Merge remote-tracking branch 'origin/development' into development

This commit is contained in:
morpheus65535 2024-08-29 07:06:21 -04:00
commit 8e8311186d
20 changed files with 593 additions and 1510 deletions

View file

@ -96,6 +96,7 @@ validators = [
Validator('general.path_mappings_movie', must_exist=True, default=[], is_type_of=list),
Validator('general.serie_tag_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.movie_tag_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.remove_profile_tags', must_exist=True, default=[], is_type_of=list, condition=validate_tags),
Validator('general.serie_default_enabled', must_exist=True, default=False, is_type_of=bool),
Validator('general.serie_default_profile', must_exist=True, default='', is_type_of=(int, str)),
Validator('general.movie_default_enabled', must_exist=True, default=False, is_type_of=bool),
@ -468,6 +469,7 @@ array_keys = ['excluded_tags',
'enabled_integrations',
'path_mappings',
'path_mappings_movie',
'remove_profile_tags',
'language_equals',
'blacklisted_languages',
'blacklisted_providers']

View file

@ -152,6 +152,10 @@ def movieParser(movie, action, tags_dict, language_profiles, movie_default_profi
tag_profile = get_matching_profile(tags, language_profiles)
if tag_profile:
parsed_movie['profileId'] = tag_profile
remove_profile_tags_list = settings.general.remove_profile_tags
if len(remove_profile_tags_list) > 0:
if set(tags) & set(remove_profile_tags_list):
parsed_movie['profileId'] = None
return parsed_movie

View file

@ -78,7 +78,11 @@ def seriesParser(show, action, tags_dict, language_profiles, serie_default_profi
tag_profile = get_matching_profile(tags, language_profiles)
if tag_profile:
parsed_series['profileId'] = tag_profile
remove_profile_tags_list = settings.general.remove_profile_tags
if len(remove_profile_tags_list) > 0:
if set(tags) & set(remove_profile_tags_list):
parsed_series['profileId'] = None
return parsed_series

View file

@ -15,12 +15,11 @@
"@typescript-eslint/no-unused-vars": "warn"
},
"extends": [
"react-app",
"plugin:react-hooks/recommended",
"eslint:recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["testing-library", "simple-import-sort"],
"plugins": ["testing-library", "simple-import-sort", "react-refresh"],
"overrides": [
{
"files": [
@ -63,6 +62,7 @@
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest"

File diff suppressed because it is too large Load diff

View file

@ -44,14 +44,16 @@
"@types/node": "^20.12.6",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.4.0",
"@vitest/ui": "^1.2.2",
"clsx": "^2.1.0",
"eslint": "^8.57.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-simple-import-sort": "^12.1.0",
"eslint-plugin-testing-library": "^6.2.0",
"husky": "^9.0.11",

View file

@ -25,23 +25,6 @@ const cacheEpisodes = (client: QueryClient, episodes: Item.Episode[]) => {
});
};
export function useEpisodesByIds(ids: number[]) {
const client = useQueryClient();
const query = useQuery({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, ids],
queryFn: () => api.episodes.byEpisodeId(ids),
});
useEffect(() => {
if (query.isSuccess && query.data) {
cacheEpisodes(client, query.data);
}
}, [query.isSuccess, query.data, client]);
return query;
}
export function useEpisodesBySeriesId(id: number) {
const client = useQueryClient();
@ -87,10 +70,11 @@ export function useEpisodeAddBlacklist() {
},
onSuccess: (_, { seriesId }) => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Series, seriesId],
});
},
@ -105,8 +89,8 @@ export function useEpisodeDeleteBlacklist() {
mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.episodes.deleteBlacklist(param.all, param.form),
onSuccess: (_) => {
client.invalidateQueries({
onSuccess: () => {
void client.invalidateQueries({
queryKey: [QueryKeys.Series, QueryKeys.Episodes, QueryKeys.Blacklist],
});
},

View file

@ -15,23 +15,6 @@ const cacheMovies = (client: QueryClient, movies: Item.Movie[]) => {
});
};
export function useMoviesByIds(ids: number[]) {
const client = useQueryClient();
const query = useQuery({
queryKey: [QueryKeys.Movies, ...ids],
queryFn: () => api.movies.movies(ids),
});
useEffect(() => {
if (query.isSuccess && query.data) {
cacheMovies(client, query.data);
}
}, [query.isSuccess, query.data, client]);
return query;
}
export function useMovieById(id: number) {
return useQuery({
queryKey: [QueryKeys.Movies, id],
@ -74,12 +57,13 @@ export function useMovieModification() {
onSuccess: (_, form) => {
form.id.forEach((v) => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies, v],
});
});
// TODO: query less
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies],
});
},
@ -93,7 +77,7 @@ export function useMovieAction() {
mutationFn: (form: FormType.MoviesAction) => api.movies.action(form),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies],
});
},
@ -125,10 +109,11 @@ export function useMovieAddBlacklist() {
},
onSuccess: (_, { id }) => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies, id],
});
},
@ -143,8 +128,8 @@ export function useMovieDeleteBlacklist() {
mutationFn: (param: { all?: boolean; form?: FormType.DeleteBlacklist }) =>
api.movies.deleteBlacklist(param.all, param.form),
onSuccess: (_, param) => {
client.invalidateQueries({
onSuccess: () => {
void client.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
});
},

View file

@ -54,22 +54,27 @@ export function useSettingsMutation() {
mutationFn: (data: LooseObject) => api.system.updateSettings(data),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Series],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Episodes],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Movies],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Wanted],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.Badges],
});
},
@ -101,7 +106,7 @@ export function useDeleteLogs() {
mutationFn: () => api.system.deleteLogs(),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Logs],
});
},
@ -128,11 +133,12 @@ export function useSystemAnnouncementsAddDismiss() {
return api.system.addAnnouncementsDismiss(hash);
},
onSuccess: (_, { hash }) => {
client.invalidateQueries({
onSuccess: () => {
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Announcements],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Badges],
});
},
@ -156,10 +162,11 @@ export function useRunTask() {
mutationFn: (id: string) => api.system.runTask(id),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Tasks],
});
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups],
});
},
@ -180,7 +187,7 @@ export function useCreateBackups() {
mutationFn: () => api.system.createBackups(),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups],
});
},
@ -194,7 +201,7 @@ export function useRestoreBackups() {
mutationFn: (filename: string) => api.system.restoreBackups(filename),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups],
});
},
@ -208,7 +215,7 @@ export function useDeleteBackups() {
mutationFn: (filename: string) => api.system.deleteBackups(filename),
onSuccess: () => {
client.invalidateQueries({
void client.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Backups],
});
},

View file

@ -16,7 +16,6 @@ type MutateActionProps<DATA, VAR> = Omit<
function MutateAction<DATA, VAR>({
mutation,
noReset,
onSuccess,
onError,
args,

View file

@ -15,7 +15,6 @@ type MutateButtonProps<DATA, VAR> = Omit<
function MutateButton<DATA, VAR>({
mutation,
noReset,
onSuccess,
onError,
args,

View file

@ -12,7 +12,7 @@ interface QueryOverlayProps {
const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({
children,
global = false,
result: { isLoading, isError, error },
result: { isLoading },
}) => {
return (
<LoadingProvider value={isLoading}>

View file

@ -7,7 +7,7 @@ import {
Select,
SelectProps,
} from "@mantine/core";
import { isNull, isUndefined, noop } from "lodash";
import { isNull, isUndefined } from "lodash";
import { LOG } from "@/utilities/console";
export type SelectorOption<T> = Override<
@ -49,10 +49,7 @@ export type GroupedSelectorProps<T> = Override<
>;
export function GroupedSelector<T>({
value,
options,
getkey = DefaultKeyBuilder,
onOptionSubmit = noop,
...select
}: GroupedSelectorProps<T>) {
return (

View file

@ -5,11 +5,8 @@ import { ModalSettings } from "@mantine/modals/lib/context";
import { ModalComponent, ModalIdContext } from "./WithModal";
export function useModals() {
const {
openContextModal: openMantineContextModal,
closeContextModal: closeContextModalRaw,
...rest
} = useMantineModals();
const { openContextModal: openMantineContextModal, ...rest } =
useMantineModals();
const openContextModal = useCallback(
<ARGS extends {}>(
@ -26,7 +23,7 @@ export function useModals() {
[openMantineContextModal],
);
const closeContextModal = useCallback(
const closeContext = useCallback(
(modal: ModalComponent) => {
rest.closeModal(modal.modalKey);
},
@ -43,7 +40,7 @@ export function useModals() {
// TODO: Performance
return useMemo(
() => ({ openContextModal, closeContextModal, closeSelf, ...rest }),
[closeContextModal, closeSelf, openContextModal, rest],
() => ({ openContextModal, closeContext, closeSelf, ...rest }),
[closeContext, closeSelf, openContextModal, rest],
);
}

View file

@ -40,13 +40,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => {
LOG("info", "Invalidating series", ids);
ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Series, id] });
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, id],
});
});
},
delete: (ids) => {
LOG("info", "Invalidating series", ids);
ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Series, id] });
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, id],
});
});
},
},
@ -55,13 +59,17 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
update: (ids) => {
LOG("info", "Invalidating movies", ids);
ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Movies, id] });
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, id],
});
});
},
delete: (ids) => {
LOG("info", "Invalidating movies", ids);
ids.forEach((id) => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.Movies, id] });
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, id],
});
});
},
},
@ -78,7 +86,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id,
]);
if (episode !== undefined) {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
});
}
@ -92,7 +100,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
id,
]);
if (episode !== undefined) {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Series, episode.sonarrSeriesId],
});
}
@ -101,28 +109,28 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
},
{
key: "episode-wanted",
update: (ids) => {
update: () => {
// Find a better way to update wanted
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
});
},
delete: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
});
},
},
{
key: "movie-wanted",
update: (ids) => {
update: () => {
// Find a better way to update wanted
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
});
},
delete: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
});
},
@ -130,13 +138,13 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "settings",
any: () => {
queryClient.invalidateQueries({ queryKey: [QueryKeys.System] });
void queryClient.invalidateQueries({ queryKey: [QueryKeys.System] });
},
},
{
key: "languages",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Languages],
});
},
@ -144,7 +152,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "badges",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Badges],
});
},
@ -152,7 +160,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "movie-history",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.History],
});
},
@ -160,7 +168,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "movie-blacklist",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Blacklist],
});
},
@ -168,7 +176,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "episode-history",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.History],
});
},
@ -176,7 +184,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "episode-blacklist",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Blacklist],
});
},
@ -184,7 +192,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "reset-episode-wanted",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Episodes, QueryKeys.Wanted],
});
},
@ -192,7 +200,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "reset-movie-wanted",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.Movies, QueryKeys.Wanted],
});
},
@ -200,7 +208,7 @@ export function createDefaultReducer(): SocketIO.Reducer[] {
{
key: "task",
any: () => {
queryClient.invalidateQueries({
void queryClient.invalidateQueries({
queryKey: [QueryKeys.System, QueryKeys.Tasks],
});
},

View file

@ -1,7 +1,9 @@
import { FunctionComponent } from "react";
import { Text as MantineText } from "@mantine/core";
import { useLanguageProfiles, useLanguages } from "@/apis/hooks";
import {
Check,
Chips,
CollapseBox,
Layout,
Message,
@ -121,12 +123,21 @@ const SettingsLanguagesView: FunctionComponent = () => {
Sonarr (or a Movie from Radarr) to find a matching Bazarr language
profile tag. It will use as the language profile the FIRST tag from
Sonarr/Radarr that matches the tag of a Bazarr language profile
EXACTLY. If mutiple tags match, there is no guarantee as to which one
EXACTLY. If multiple tags match, there is no guarantee as to which one
will be used, so choose your tag names carefully. Also, if you update
the tag names in Sonarr/Radarr, Bazarr will detect this and repeat the
matching process for the affected shows. However, if a show's only
matching tag is removed from Sonarr/Radarr, Bazarr will NOT remove the
show's existing language profile, but keep it, as is.
show's existing language profile for that reason. But if you wish to
have language profiles removed automatically by tag value, simply
enter a list of one or more tags in the{" "}
<MantineText fw={700} span>
Remove Profile Tags
</MantineText>{" "}
entry list below. If your video tag matches one of the tags in that
list, then Bazarr will remove the language profile for that video. If
there is a conflict between profile selection and profile removal,
then profile removal wins out and is performed.
</Message>
<Check
label="Series"
@ -136,6 +147,19 @@ const SettingsLanguagesView: FunctionComponent = () => {
label="Movies"
settingKey="settings-general-movie_tag_enabled"
></Check>
<Chips
label="Remove Profile Tags"
settingKey="settings-general-remove_profile_tags"
sanitizeFn={(values: string[] | null) =>
values?.map((item) =>
item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(),
)
}
></Chips>
<Message>
Enter tag values that will trigger a language profile removal. Leave
empty if you don't want Bazarr to remove language profiles.
</Message>
</Section>
<Section header="Default Settings">
<Check

View file

@ -1,5 +1,5 @@
import { FunctionComponent } from "react";
import { Code, Space, Table } from "@mantine/core";
import React, { FunctionComponent } from "react";
import { Code, Space, Table, Text as MantineText } from "@mantine/core";
import {
Check,
CollapseBox,
@ -115,14 +115,16 @@ const commandOptions: CommandOption[] = [
},
];
const commandOptionElements: JSX.Element[] = commandOptions.map((op, idx) => (
<tr key={idx}>
<td>
<Code>{op.option}</Code>
</td>
<td>{op.description}</td>
</tr>
));
const commandOptionElements: React.JSX.Element[] = commandOptions.map(
(op, idx) => (
<tr key={idx}>
<td>
<Code>{op.option}</Code>
</td>
<td>{op.description}</td>
</tr>
),
);
const SettingsSubtitlesView: FunctionComponent = () => {
return (
@ -436,8 +438,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_threshold"></Slider>
<Space />
<Message>
Only series subtitles with scores <b>below</b> this value will be
automatically synchronized.
Only series subtitles with scores{" "}
<MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically synchronized.
</Message>
</CollapseBox>
<Check
@ -451,8 +456,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-subsync-subsync_movie_threshold"></Slider>
<Space />
<Message>
Only movie subtitles with scores <b>below</b> this value will be
automatically synchronized.
Only movie subtitles with scores{" "}
<MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically synchronized.
</Message>
</CollapseBox>
</CollapseBox>
@ -478,8 +486,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold"></Slider>
<Space />
<Message>
Only series subtitles with scores <b>below</b> this value will be
automatically post-processed.
Only series subtitles with scores{" "}
<MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically post-processed.
</Message>
</CollapseBox>
<Check
@ -493,8 +504,11 @@ const SettingsSubtitlesView: FunctionComponent = () => {
<Slider settingKey="settings-general-postprocessing_threshold_movie"></Slider>
<Space />
<Message>
Only movie subtitles with scores <b>below</b> this value will be
automatically post-processed.
Only movie subtitles with scores{" "}
<MantineText fw={700} span>
below
</MantineText>{" "}
this value will be automatically post-processed.
</Message>
</CollapseBox>
<Text

View file

@ -2,7 +2,7 @@ import { FunctionComponent, PropsWithChildren, ReactElement } from "react";
import { useForm } from "@mantine/form";
import { describe, it } from "vitest";
import { FormContext, FormValues } from "@/pages/Settings/utilities/FormValues";
import { render, RenderOptions, screen } from "@/tests";
import { render, screen } from "@/tests";
import { Number, Text } from "./forms";
const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
@ -15,10 +15,8 @@ const FormSupport: FunctionComponent<PropsWithChildren> = ({ children }) => {
return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
};
const formRender = (
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) => render(<FormSupport>{ui}</FormSupport>);
const formRender = (ui: ReactElement) =>
render(<FormSupport>{ui}</FormSupport>);
describe("Settings form", () => {
describe("number component", () => {

View file

@ -1,4 +1,4 @@
import { FunctionComponent, ReactNode, ReactText } from "react";
import { FunctionComponent, ReactNode } from "react";
import {
Input,
NumberInput,
@ -49,7 +49,7 @@ export const Number: FunctionComponent<NumberProps> = (props) => {
);
};
export type TextProps = BaseInput<ReactText> & TextInputProps;
export type TextProps = BaseInput<string | number> & TextInputProps;
export const Text: FunctionComponent<TextProps> = (props) => {
const { value, update, rest } = useBaseInput(props);
@ -86,11 +86,7 @@ export interface CheckProps extends BaseInput<boolean> {
inline?: boolean;
}
export const Check: FunctionComponent<CheckProps> = ({
label,
inline,
...props
}) => {
export const Check: FunctionComponent<CheckProps> = ({ label, ...props }) => {
const { value, update, rest } = useBaseInput(props);
return (

View file

@ -62,6 +62,7 @@ declare namespace Settings {
postprocessing_cmd?: string;
postprocessing_threshold: number;
postprocessing_threshold_movie: number;
remove_profile_tags: string[];
single_language: boolean;
subfolder: string;
subfolder_custom?: string;