diff options
Diffstat (limited to 'frontend/src')
41 files changed, 556 insertions, 258 deletions
diff --git a/frontend/src/Router/index.tsx b/frontend/src/Router/index.tsx index d600fc87d..8ccea87f9 100644 --- a/frontend/src/Router/index.tsx +++ b/frontend/src/Router/index.tsx @@ -270,6 +270,7 @@ function useRoutes(): CustomRouteObject[] { { path: "status", name: "Status", + badge: data?.status, element: ( <Lazy> <SystemStatusView></SystemStatusView> @@ -309,6 +310,7 @@ function useRoutes(): CustomRouteObject[] { data?.sonarr_signalr, data?.radarr_signalr, data?.announcements, + data?.status, radarr, sonarr, ], diff --git a/frontend/src/apis/hooks/episodes.ts b/frontend/src/apis/hooks/episodes.ts index 6a489e938..956fd103f 100644 --- a/frontend/src/apis/hooks/episodes.ts +++ b/frontend/src/apis/hooks/episodes.ts @@ -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], }); }, diff --git a/frontend/src/apis/hooks/movies.ts b/frontend/src/apis/hooks/movies.ts index cf4594cbe..6b1c5c2a5 100644 --- a/frontend/src/apis/hooks/movies.ts +++ b/frontend/src/apis/hooks/movies.ts @@ -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], }); }, diff --git a/frontend/src/apis/hooks/system.ts b/frontend/src/apis/hooks/system.ts index 109e77105..a0ce17fb9 100644 --- a/frontend/src/apis/hooks/system.ts +++ b/frontend/src/apis/hooks/system.ts @@ -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], }); }, diff --git a/frontend/src/assets/badge.module.scss b/frontend/src/assets/badge.module.scss index 4b8717fe3..5b31be59e 100644 --- a/frontend/src/assets/badge.module.scss +++ b/frontend/src/assets/badge.module.scss @@ -47,4 +47,8 @@ } } } + + .label { + overflow: visible; + } } diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx index b506afee3..c0dde3bef 100644 --- a/frontend/src/components/Search.tsx +++ b/frontend/src/components/Search.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Autocomplete, ComboboxItem, OptionsFilter, Text } from "@mantine/core"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { chain, includes } from "lodash"; import { useServerSearch } from "@/apis/hooks"; import { useDebouncedValue } from "@/utilities"; @@ -15,23 +16,45 @@ function useSearch(query: string) { const debouncedQuery = useDebouncedValue(query, 500); const { data } = useServerSearch(debouncedQuery, debouncedQuery.length >= 0); + const duplicates = chain(data) + .groupBy((item) => `${item.title} (${item.year})`) + .filter((group) => group.length > 1) + .map((group) => `${group[0].title} (${group[0].year})`) + .value(); + return useMemo<SearchResultItem[]>( () => data?.map((v) => { - let link: string; - if (v.sonarrSeriesId) { - link = `/series/${v.sonarrSeriesId}`; - } else if (v.radarrId) { - link = `/movies/${v.radarrId}`; - } else { + const { link, displayName } = (() => { + const hasDuplicate = includes(duplicates, `${v.title} (${v.year})`); + + if (v.sonarrSeriesId) { + return { + link: `/series/${v.sonarrSeriesId}`, + displayName: hasDuplicate + ? `${v.title} (${v.year}) (S)` + : `${v.title} (${v.year})`, + }; + } + + if (v.radarrId) { + return { + link: `/movies/${v.radarrId}`, + displayName: hasDuplicate + ? `${v.title} (${v.year}) (M)` + : `${v.title} (${v.year})`, + }; + } + throw new Error("Unknown search result"); - } + })(); + return { - value: `${v.title} (${v.year})`, + value: displayName, link, }; }) ?? [], - [data], + [data, duplicates], ); } diff --git a/frontend/src/components/TextPopover.tsx b/frontend/src/components/TextPopover.tsx index 974c0d0c0..03dd58700 100644 --- a/frontend/src/components/TextPopover.tsx +++ b/frontend/src/components/TextPopover.tsx @@ -25,7 +25,7 @@ const TextPopover: FunctionComponent<TextPopoverProps> = ({ opened={hovered} label={text} {...tooltip} - style={{ textWrap: "pretty" }} + style={{ textWrap: "wrap" }} > <div ref={ref}>{children}</div> </Tooltip> diff --git a/frontend/src/components/async/MutateAction.tsx b/frontend/src/components/async/MutateAction.tsx index 6fff0dbb7..92c102ea9 100644 --- a/frontend/src/components/async/MutateAction.tsx +++ b/frontend/src/components/async/MutateAction.tsx @@ -16,7 +16,6 @@ type MutateActionProps<DATA, VAR> = Omit< function MutateAction<DATA, VAR>({ mutation, - noReset, onSuccess, onError, args, diff --git a/frontend/src/components/async/MutateButton.tsx b/frontend/src/components/async/MutateButton.tsx index 9197e2d50..908c2dfda 100644 --- a/frontend/src/components/async/MutateButton.tsx +++ b/frontend/src/components/async/MutateButton.tsx @@ -15,7 +15,6 @@ type MutateButtonProps<DATA, VAR> = Omit< function MutateButton<DATA, VAR>({ mutation, - noReset, onSuccess, onError, args, diff --git a/frontend/src/components/async/QueryOverlay.tsx b/frontend/src/components/async/QueryOverlay.tsx index 2a5848cf2..1672989ff 100644 --- a/frontend/src/components/async/QueryOverlay.tsx +++ b/frontend/src/components/async/QueryOverlay.tsx @@ -12,7 +12,7 @@ interface QueryOverlayProps { const QueryOverlay: FunctionComponent<QueryOverlayProps> = ({ children, global = false, - result: { isLoading, isError, error }, + result: { isLoading }, }) => { return ( <LoadingProvider value={isLoading}> diff --git a/frontend/src/components/bazarr/AudioList.tsx b/frontend/src/components/bazarr/AudioList.tsx index f1af7ff3c..f0dc07c0d 100644 --- a/frontend/src/components/bazarr/AudioList.tsx +++ b/frontend/src/components/bazarr/AudioList.tsx @@ -1,6 +1,7 @@ import { FunctionComponent } from "react"; import { Badge, BadgeProps, Group, GroupProps } from "@mantine/core"; import { BuildKey } from "@/utilities"; +import { normalizeAudioLanguage } from "@/utilities/languages"; export type AudioListProps = GroupProps & { audios: Language.Info[]; @@ -16,7 +17,7 @@ const AudioList: FunctionComponent<AudioListProps> = ({ <Group gap="xs" {...group}> {audios.map((audio, idx) => ( <Badge color="blue" key={BuildKey(idx, audio.code2)} {...badgeProps}> - {audio.name} + {normalizeAudioLanguage(audio.name)} </Badge> ))} </Group> diff --git a/frontend/src/components/forms/MovieUploadForm.tsx b/frontend/src/components/forms/MovieUploadForm.tsx index 8e318d7ad..f7f8f47c5 100644 --- a/frontend/src/components/forms/MovieUploadForm.tsx +++ b/frontend/src/components/forms/MovieUploadForm.tsx @@ -1,9 +1,9 @@ -import { FunctionComponent, useEffect, useMemo } from "react"; +import React, { FunctionComponent, useEffect, useMemo } from "react"; import { Button, - Checkbox, Divider, MantineColor, + Select, Stack, Text, } from "@mantine/core"; @@ -17,8 +17,9 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ColumnDef } from "@tanstack/react-table"; -import { isString } from "lodash"; +import { isString, uniqBy } from "lodash"; import { useMovieSubtitleModification } from "@/apis/hooks"; +import { subtitlesTypeOptions } from "@/components/forms/uploadFormSelectorTypes"; import { Action, Selector } from "@/components/inputs"; import SimpleTable from "@/components/tables/SimpleTable"; import TextPopover from "@/components/TextPopover"; @@ -88,7 +89,7 @@ const MovieUploadForm: FunctionComponent<Props> = ({ const languages = useProfileItemsToLanguages(profile); const languageOptions = useSelectorOptions( - languages, + uniqBy(languages, "code2"), (v) => v.name, (v) => v.code2, ); @@ -208,34 +209,6 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, }, { - header: "Forced", - accessorKey: "forced", - cell: ({ row: { original, index } }) => { - return ( - <Checkbox - checked={original.forced} - onChange={({ currentTarget: { checked } }) => { - action.mutate(index, { ...original, forced: checked }); - }} - ></Checkbox> - ); - }, - }, - { - header: "HI", - accessorKey: "hi", - cell: ({ row: { original, index } }) => { - return ( - <Checkbox - checked={original.hi} - onChange={({ currentTarget: { checked } }) => { - action.mutate(index, { ...original, hi: checked }); - }} - ></Checkbox> - ); - }, - }, - { header: "Language", accessorKey: "language", cell: ({ row: { original, index } }) => { @@ -252,6 +225,61 @@ const MovieUploadForm: FunctionComponent<Props> = ({ }, }, { + header: () => ( + <Selector + options={subtitlesTypeOptions} + value={null} + placeholder="Type" + onChange={(value) => { + if (value) { + action.update((item) => { + switch (value) { + case "hi": + return { ...item, hi: true, forced: false }; + case "forced": + return { ...item, hi: false, forced: true }; + case "normal": + return { ...item, hi: false, forced: false }; + default: + return item; + } + }); + } + }} + ></Selector> + ), + accessorKey: "type", + cell: ({ row: { original, index } }) => { + return ( + <Select + value={ + subtitlesTypeOptions.find((s) => { + if (original.hi) { + return s.value === "hi"; + } + + if (original.forced) { + return s.value === "forced"; + } + + return s.value === "normal"; + })?.value + } + data={subtitlesTypeOptions} + onChange={(value) => { + if (value) { + action.mutate(index, { + ...original, + hi: value === "hi", + forced: value === "forced", + }); + } + }} + ></Select> + ); + }, + }, + { id: "action", cell: ({ row: { index } }) => { return ( diff --git a/frontend/src/components/forms/ProfileEditForm.module.scss b/frontend/src/components/forms/ProfileEditForm.module.scss index d98b850ff..3d4a8e177 100644 --- a/frontend/src/components/forms/ProfileEditForm.module.scss +++ b/frontend/src/components/forms/ProfileEditForm.module.scss @@ -3,3 +3,11 @@ padding: 0; } } + +.evenly { + flex-wrap: wrap; + + & > div { + flex: 1; + } +} diff --git a/frontend/src/components/forms/ProfileEditForm.tsx b/frontend/src/components/forms/ProfileEditForm.tsx index 75e2f9df7..267951fcb 100644 --- a/frontend/src/components/forms/ProfileEditForm.tsx +++ b/frontend/src/components/forms/ProfileEditForm.tsx @@ -3,6 +3,7 @@ import { Accordion, Button, Checkbox, + Flex, Select, Stack, Switch, @@ -72,9 +73,16 @@ const ProfileEditForm: FunctionComponent<Props> = ({ (value) => value.length > 0, "Must have a name", ), + tag: FormUtils.validation((value) => { + if (!value) { + return true; + } + + return /^[a-z_0-9-]+$/.test(value); + }, "Only lowercase alphanumeric characters, underscores (_) and hyphens (-) are allowed"), items: FormUtils.validation( (value) => value.length > 0, - "Must contain at lease 1 language", + "Must contain at least 1 language", ), }, }); @@ -265,7 +273,24 @@ const ProfileEditForm: FunctionComponent<Props> = ({ })} > <Stack> - <TextInput label="Name" {...form.getInputProps("name")}></TextInput> + <Flex + direction={{ base: "column", sm: "row" }} + gap="sm" + className={styles.evenly} + > + <TextInput label="Name" {...form.getInputProps("name")}></TextInput> + <TextInput + label="Tag" + {...form.getInputProps("tag")} + onBlur={() => + form.setFieldValue( + "tag", + (prev) => + prev?.toLowerCase().trim().replace(/\s+/g, "_") ?? undefined, + ) + } + ></TextInput> + </Flex> <Accordion multiple chevronPosition="right" @@ -274,7 +299,6 @@ const ProfileEditForm: FunctionComponent<Props> = ({ > <Accordion.Item value="Languages"> <Stack> - {form.errors.items} <SimpleTable columns={columns} data={form.values.items} @@ -282,6 +306,7 @@ const ProfileEditForm: FunctionComponent<Props> = ({ <Button fullWidth onClick={addItem}> Add Language </Button> + <Text c="var(--mantine-color-error)">{form.errors.items}</Text> <Selector clearable label="Cutoff" diff --git a/frontend/src/components/forms/SeriesUploadForm.tsx b/frontend/src/components/forms/SeriesUploadForm.tsx index e4482cab4..9ae6308c9 100644 --- a/frontend/src/components/forms/SeriesUploadForm.tsx +++ b/frontend/src/components/forms/SeriesUploadForm.tsx @@ -1,9 +1,9 @@ -import { FunctionComponent, useEffect, useMemo } from "react"; +import React, { FunctionComponent, useEffect, useMemo } from "react"; import { Button, - Checkbox, Divider, MantineColor, + Select, Stack, Text, } from "@mantine/core"; @@ -17,12 +17,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ColumnDef } from "@tanstack/react-table"; -import { isString } from "lodash"; +import { isString, uniqBy } from "lodash"; import { useEpisodesBySeriesId, useEpisodeSubtitleModification, useSubtitleInfos, } from "@/apis/hooks"; +import { subtitlesTypeOptions } from "@/components/forms/uploadFormSelectorTypes"; import { Action, Selector } from "@/components/inputs"; import SimpleTable from "@/components/tables/SimpleTable"; import TextPopover from "@/components/TextPopover"; @@ -100,7 +101,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ const profile = useLanguageProfileBy(series.profileId); const languages = useProfileItemsToLanguages(profile); const languageOptions = useSelectorOptions( - languages, + uniqBy(languages, "code2"), (v) => v.name, (v) => v.code2, ); @@ -236,42 +237,6 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, }, { - header: "Forced", - accessorKey: "forced", - cell: ({ row: { original, index } }) => { - return ( - <Checkbox - checked={original.forced} - onChange={({ currentTarget: { checked } }) => { - action.mutate(index, { - ...original, - forced: checked, - hi: checked ? false : original.hi, - }); - }} - ></Checkbox> - ); - }, - }, - { - header: "HI", - accessorKey: "hi", - cell: ({ row: { original, index } }) => { - return ( - <Checkbox - checked={original.hi} - onChange={({ currentTarget: { checked } }) => { - action.mutate(index, { - ...original, - hi: checked, - forced: checked ? false : original.forced, - }); - }} - ></Checkbox> - ); - }, - }, - { header: () => ( <Selector {...languageOptions} @@ -280,8 +245,7 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ onChange={(value) => { if (value) { action.update((item) => { - item.language = value; - return item; + return { ...item, language: value }; }); } }} @@ -302,6 +266,61 @@ const SeriesUploadForm: FunctionComponent<Props> = ({ }, }, { + header: () => ( + <Selector + options={subtitlesTypeOptions} + value={null} + placeholder="Type" + onChange={(value) => { + if (value) { + action.update((item) => { + switch (value) { + case "hi": + return { ...item, hi: true, forced: false }; + case "forced": + return { ...item, hi: false, forced: true }; + case "normal": + return { ...item, hi: false, forced: false }; + default: + return item; + } + }); + } + }} + ></Selector> + ), + accessorKey: "type", + cell: ({ row: { original, index } }) => { + return ( + <Select + value={ + subtitlesTypeOptions.find((s) => { + if (original.hi) { + return s.value === "hi"; + } + + if (original.forced) { + return s.value === "forced"; + } + + return s.value === "normal"; + })?.value + } + data={subtitlesTypeOptions} + onChange={(value) => { + if (value) { + action.mutate(index, { + ...original, + hi: value === "hi", + forced: value === "forced", + }); + } + }} + ></Select> + ); + }, + }, + { id: "episode", header: "Episode", accessorKey: "episode", diff --git a/frontend/src/components/forms/uploadFormSelectorTypes.tsx b/frontend/src/components/forms/uploadFormSelectorTypes.tsx new file mode 100644 index 000000000..168cdddb1 --- /dev/null +++ b/frontend/src/components/forms/uploadFormSelectorTypes.tsx @@ -0,0 +1,16 @@ +import { SelectorOption } from "@/components"; + +export const subtitlesTypeOptions: SelectorOption<string>[] = [ + { + label: "Normal", + value: "normal", + }, + { + label: "Hearing-Impaired", + value: "hi", + }, + { + label: "Forced", + value: "forced", + }, +]; diff --git a/frontend/src/components/inputs/Selector.tsx b/frontend/src/components/inputs/Selector.tsx index 1825d314a..092fd24e7 100644 --- a/frontend/src/components/inputs/Selector.tsx +++ b/frontend/src/components/inputs/Selector.tsx @@ -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 ( diff --git a/frontend/src/modules/modals/hooks.ts b/frontend/src/modules/modals/hooks.ts index 09855ac51..667e429d3 100644 --- a/frontend/src/modules/modals/hooks.ts +++ b/frontend/src/modules/modals/hooks.ts @@ -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], ); } diff --git a/frontend/src/modules/socketio/reducer.ts b/frontend/src/modules/socketio/reducer.ts index d19ff87c4..378ab12fc 100644 --- a/frontend/src/modules/socketio/reducer.ts +++ b/frontend/src/modules/socketio/reducer.ts @@ -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], }); }, diff --git a/frontend/src/pages/Movies/index.tsx b/frontend/src/pages/Movies/index.tsx index 0429e1fdd..ef5a1ec0d 100644 --- a/frontend/src/pages/Movies/index.tsx +++ b/frontend/src/pages/Movies/index.tsx @@ -6,6 +6,7 @@ import { faBookmark as farBookmark } from "@fortawesome/free-regular-svg-icons"; import { faBookmark, faWrench } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ColumnDef } from "@tanstack/react-table"; +import { uniqueId } from "lodash"; import { useMovieModification, useMoviesPagination } from "@/apis/hooks"; import { Action } from "@/components"; import { AudioList } from "@/components/bazarr"; @@ -95,7 +96,7 @@ const MovieView: FunctionComponent = () => { <Badge mr="xs" color="yellow" - key={BuildKey(v.code2, v.hi, v.forced)} + key={uniqueId(`${BuildKey(v.code2, v.hi, v.forced)}_`)} > <Language.Text value={v}></Language.Text> </Badge> diff --git a/frontend/src/pages/Series/index.tsx b/frontend/src/pages/Series/index.tsx index 229082444..c142a6767 100644 --- a/frontend/src/pages/Series/index.tsx +++ b/frontend/src/pages/Series/index.tsx @@ -65,25 +65,34 @@ const SeriesView: FunctionComponent = () => { cell: (row) => { const { episodeFileCount, episodeMissingCount, profileId, title } = row.row.original; - let progress = 0; - let label = ""; - if (episodeFileCount === 0 || !profileId) { - progress = 0.0; - } else { - progress = (1.0 - episodeMissingCount / episodeFileCount) * 100.0; - label = `${ - episodeFileCount - episodeMissingCount - }/${episodeFileCount}`; - } + const label = `${episodeFileCount - episodeMissingCount}/${episodeFileCount}`; return ( <Progress.Root key={title} size="xl"> <Progress.Section - value={progress} + value={ + episodeFileCount === 0 || !profileId + ? 0 + : (1.0 - episodeMissingCount / episodeFileCount) * 100.0 + } color={episodeMissingCount === 0 ? "brand" : "yellow"} > <Progress.Label>{label}</Progress.Label> </Progress.Section> + {episodeMissingCount === episodeFileCount && ( + <Progress.Label + styles={{ + label: { + position: "absolute", + top: "3px", + left: "50%", + transform: "translateX(-50%)", + }, + }} + > + {label} + </Progress.Label> + )} </Progress.Root> ); }, diff --git a/frontend/src/pages/Settings/General/index.tsx b/frontend/src/pages/Settings/General/index.tsx index 312d09d1f..6db3ee7fc 100644 --- a/frontend/src/pages/Settings/General/index.tsx +++ b/frontend/src/pages/Settings/General/index.tsx @@ -43,10 +43,10 @@ const SettingsGeneralView: FunctionComponent = () => { <Section header="Host"> <Text label="Address" - placeholder="0.0.0.0" + placeholder="*" settingKey="settings-general-ip" ></Text> - <Message>Valid IPv4 address or '0.0.0.0' for all interfaces</Message> + <Message>Valid IP address or '*' for all interfaces</Message> <Number label="Port" placeholder="6767" diff --git a/frontend/src/pages/Settings/Languages/index.tsx b/frontend/src/pages/Settings/Languages/index.tsx index 9fe562920..1bd9d72a8 100644 --- a/frontend/src/pages/Settings/Languages/index.tsx +++ b/frontend/src/pages/Settings/Languages/index.tsx @@ -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, @@ -115,6 +117,50 @@ const SettingsLanguagesView: FunctionComponent = () => { <Section header="Languages Profile"> <Table></Table> </Section> + <Section header="Tag-Based Automatic Language Profile Selection Settings"> + <Message> + If enabled, Bazarr will look at the names of all tags of a Series from + 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 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 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" + settingKey="settings-general-serie_tag_enabled" + ></Check> + <Check + 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 label="Series" diff --git a/frontend/src/pages/Settings/Languages/table.tsx b/frontend/src/pages/Settings/Languages/table.tsx index c32300628..5cfefdfa9 100644 --- a/frontend/src/pages/Settings/Languages/table.tsx +++ b/frontend/src/pages/Settings/Languages/table.tsx @@ -2,7 +2,7 @@ import { FunctionComponent, useCallback, useMemo } from "react"; import { Badge, Button, Group } from "@mantine/core"; import { faTrash, faWrench } from "@fortawesome/free-solid-svg-icons"; import { ColumnDef } from "@tanstack/react-table"; -import { cloneDeep } from "lodash"; +import { cloneDeep, includes, maxBy } from "lodash"; import { Action } from "@/components"; import { anyCutoff, @@ -66,6 +66,10 @@ const Table: FunctionComponent = () => { accessorKey: "name", }, { + header: "Tag", + accessorKey: "tag", + }, + { header: "Languages", accessorKey: "items", cell: ({ @@ -75,10 +79,10 @@ const Table: FunctionComponent = () => { }) => { return ( <Group gap="xs" wrap="nowrap"> - {items.map((v) => { + {items.map((v, i) => { const isCutoff = v.id === cutoff || cutoff === anyCutoff; return ( - <ItemBadge key={v.id} cutoff={isCutoff} item={v}></ItemBadge> + <ItemBadge key={i} cutoff={isCutoff} item={v}></ItemBadge> ); })} </Group> @@ -144,9 +148,45 @@ const Table: FunctionComponent = () => { icon={faWrench} c="gray" onClick={() => { + const lastId = maxBy(profile.items, "id")?.id || 0; + + // We once had an issue on the past where there were duplicated + // item ids that needs to become unique upon editing. + const sanitizedProfile = { + ...cloneDeep(profile), + items: profile.items.reduce( + (acc, value) => { + const { ids, duplicatedIds, items } = acc; + + // We once had an issue on the past where there were duplicated + // item ids that needs to become unique upon editing. + if (includes(ids, value.id)) { + duplicatedIds.push(value.id); + items.push({ + ...value, + id: lastId + duplicatedIds.length, + }); + + return acc; + } + + ids.push(value.id); + items.push(value); + + return acc; + }, + { + ids: [] as number[], + duplicatedIds: [] as number[], + items: [] as typeof profile.items, + }, + ).items, + tag: profile.tag || undefined, + }; + modals.openContextModal(ProfileEditModal, { languages, - profile: cloneDeep(profile), + profile: sanitizedProfile, onComplete: updateProfile, }); }} @@ -178,6 +218,7 @@ const Table: FunctionComponent = () => { const profile = { profileId: nextProfileId, name: "", + tag: undefined, items: [], cutoff: null, mustContain: [], diff --git a/frontend/src/pages/Settings/Providers/components.tsx b/frontend/src/pages/Settings/Providers/components.tsx index 72e2c3b1f..acae15261 100644 --- a/frontend/src/pages/Settings/Providers/components.tsx +++ b/frontend/src/pages/Settings/Providers/components.tsx @@ -108,10 +108,12 @@ export const ProviderView: FunctionComponent<ProviderViewProps> = ({ }) .map((v, idx) => ( <Card + titleStyles={{ overflow: "hidden", textOverflow: "ellipsis" }} key={BuildKey(v.key, idx)} header={v.name ?? capitalize(v.key)} description={v.description} onClick={() => select(v)} + lineClamp={2} ></Card> )); } else { diff --git a/frontend/src/pages/Settings/Providers/list.ts b/frontend/src/pages/Settings/Providers/list.ts index b2f9a33c7..8f0e46a56 100644 --- a/frontend/src/pages/Settings/Providers/list.ts +++ b/frontend/src/pages/Settings/Providers/list.ts @@ -218,6 +218,35 @@ export const ProviderList: Readonly<ProviderInfo[]> = [ }, ], }, + { + key: "jimaku", + name: "Jimaku.cc", + description: "Japanese Subtitles Provider", + message: + "API key required. Subtitles stem from various sources and might have quality/timing issues.", + inputs: [ + { + type: "password", + key: "api_key", + name: "API key", + }, + { + type: "switch", + key: "enable_name_search_fallback", + name: "Search by name if no AniList ID was determined (Less accurate, required for live action)", + }, + { + type: "switch", + key: "enable_archives_download", + name: "Also consider archives alongside uncompressed subtitles", + }, + { + type: "switch", + key: "enable_ai_subs", + name: "Download AI generated subtitles", + }, + ], + }, { key: "hosszupuska", description: "Hungarian Subtitles Provider" }, { key: "karagarga", @@ -276,6 +305,21 @@ export const ProviderList: Readonly<ProviderInfo[]> = [ { type: "switch", key: "skip_wrong_fps", name: "Skip Wrong FPS" }, ], }, + { + key: "legendasnet", + name: "Legendas.net", + description: "Brazilian Subtitles Provider", + inputs: [ + { + type: "text", + key: "username", + }, + { + type: "password", + key: "password", + }, + ], + }, { key: "napiprojekt", description: "Polish Subtitles Provider" }, { key: "napisy24", diff --git a/frontend/src/pages/Settings/Radarr/index.tsx b/frontend/src/pages/Settings/Radarr/index.tsx index b2e858178..264c78924 100644 --- a/frontend/src/pages/Settings/Radarr/index.tsx +++ b/frontend/src/pages/Settings/Radarr/index.tsx @@ -54,6 +54,11 @@ const SettingsRadarrView: FunctionComponent = () => { <Chips label="Excluded Tags" settingKey="settings-radarr-excluded_tags" + sanitizeFn={(values: string[] | null) => + values?.map((item) => + item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(), + ) + } ></Chips> <Message> Movies with those tags (case sensitive) in Radarr will be excluded diff --git a/frontend/src/pages/Settings/Sonarr/index.tsx b/frontend/src/pages/Settings/Sonarr/index.tsx index ed66ef679..ff4ac6ca2 100644 --- a/frontend/src/pages/Settings/Sonarr/index.tsx +++ b/frontend/src/pages/Settings/Sonarr/index.tsx @@ -56,6 +56,11 @@ const SettingsSonarrView: FunctionComponent = () => { <Chips label="Excluded Tags" settingKey="settings-sonarr-excluded_tags" + sanitizeFn={(values: string[] | null) => + values?.map((item) => + item.replace(/[^a-z0-9_-]/gi, "").toLowerCase(), + ) + } ></Chips> <Message> Episodes from series with those tags (case sensitive) in Sonarr will diff --git a/frontend/src/pages/Settings/Subtitles/index.tsx b/frontend/src/pages/Settings/Subtitles/index.tsx index a2250e5a9..a2e05a5c5 100644 --- a/frontend/src/pages/Settings/Subtitles/index.tsx +++ b/frontend/src/pages/Settings/Subtitles/index.tsx @@ -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 diff --git a/frontend/src/pages/Settings/components/Card.tsx b/frontend/src/pages/Settings/components/Card.tsx index 69df15636..a8a33eec3 100644 --- a/frontend/src/pages/Settings/components/Card.tsx +++ b/frontend/src/pages/Settings/components/Card.tsx @@ -1,14 +1,23 @@ import { FunctionComponent } from "react"; -import { Center, Stack, Text, UnstyledButton } from "@mantine/core"; +import { + Center, + MantineStyleProp, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import TextPopover from "@/components/TextPopover"; import styles from "./Card.module.scss"; interface CardProps { - header?: string; description?: string; - plus?: boolean; + header?: string; + lineClamp?: number | undefined; onClick?: () => void; + plus?: boolean; + titleStyles?: MantineStyleProp | undefined; } export const Card: FunctionComponent<CardProps> = ({ @@ -16,6 +25,8 @@ export const Card: FunctionComponent<CardProps> = ({ description, plus, onClick, + lineClamp, + titleStyles, }) => { return ( <UnstyledButton p="lg" onClick={onClick} className={styles.card}> @@ -24,9 +35,15 @@ export const Card: FunctionComponent<CardProps> = ({ <FontAwesomeIcon size="2x" icon={faPlus}></FontAwesomeIcon> </Center> ) : ( - <Stack h="100%" gap={0} align="flex-start"> - <Text fw="bold">{header}</Text> - <Text hidden={description === undefined}>{description}</Text> + <Stack h="100%" gap={0}> + <Text fw="bold" style={titleStyles}> + {header} + </Text> + <TextPopover text={description}> + <Text hidden={description === undefined} lineClamp={lineClamp}> + {description} + </Text> + </TextPopover> </Stack> )} </UnstyledButton> diff --git a/frontend/src/pages/Settings/components/forms.test.tsx b/frontend/src/pages/Settings/components/forms.test.tsx index a88d2bec7..4ec60699b 100644 --- a/frontend/src/pages/Settings/components/forms.test.tsx +++ b/frontend/src/pages/Settings/components/forms.test.tsx @@ -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", () => { diff --git a/frontend/src/pages/Settings/components/forms.tsx b/frontend/src/pages/Settings/components/forms.tsx index 95134db92..43b559736 100644 --- a/frontend/src/pages/Settings/components/forms.tsx +++ b/frontend/src/pages/Settings/components/forms.tsx @@ -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 ( @@ -160,13 +156,25 @@ export const Slider: FunctionComponent<SliderProps> = (props) => { }; type ChipsProp = BaseInput<string[]> & - Omit<ChipInputProps, "onChange" | "data">; + Omit<ChipInputProps, "onChange" | "data"> & { + sanitizeFn?: (values: string[] | null) => string[] | undefined; + }; export const Chips: FunctionComponent<ChipsProp> = (props) => { const { value, update, rest } = useBaseInput(props); + const handleChange = (value: string[] | null) => { + const sanitizedValues = props.sanitizeFn?.(value) ?? value; + + update(sanitizedValues || null); + }; + return ( - <ChipInput {...rest} value={value ?? []} onChange={update}></ChipInput> + <ChipInput + {...rest} + value={value ?? []} + onChange={handleChange} + ></ChipInput> ); }; diff --git a/frontend/src/pages/System/Announcements/table.tsx b/frontend/src/pages/System/Announcements/table.tsx index febb32fa1..910fb4bd5 100644 --- a/frontend/src/pages/System/Announcements/table.tsx +++ b/frontend/src/pages/System/Announcements/table.tsx @@ -19,7 +19,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => { () => [ { header: "Since", - accessor: "timestamp", + accessorKey: "timestamp", cell: ({ row: { original: { timestamp }, @@ -30,7 +30,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => { }, { header: "Announcement", - accessor: "text", + accessorKey: "text", cell: ({ row: { original: { text }, @@ -41,7 +41,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => { }, { header: "More Info", - accessor: "link", + accessorKey: "link", cell: ({ row: { original: { link }, @@ -56,7 +56,7 @@ const Table: FunctionComponent<Props> = ({ announcements }) => { }, { header: "Dismiss", - accessor: "hash", + accessorKey: "hash", cell: ({ row: { original: { dismissible, hash }, diff --git a/frontend/src/pages/System/Status/index.tsx b/frontend/src/pages/System/Status/index.tsx index bcd0e175d..157935dfb 100644 --- a/frontend/src/pages/System/Status/index.tsx +++ b/frontend/src/pages/System/Status/index.tsx @@ -144,6 +144,8 @@ const SystemStatusView: FunctionComponent = () => { <Row title="Radarr Version">{status?.radarr_version}</Row> <Row title="Operating System">{status?.operating_system}</Row> <Row title="Python Version">{status?.python_version}</Row> + <Row title="Database Engine">{status?.database_engine}</Row> + <Row title="Database Version">{status?.database_migration}</Row> <Row title="Bazarr Directory">{status?.bazarr_directory}</Row> <Row title="Bazarr Config Directory"> {status?.bazarr_config_directory} diff --git a/frontend/src/pages/System/Tasks/table.tsx b/frontend/src/pages/System/Tasks/table.tsx index ed3248b6f..5e1b045bd 100644 --- a/frontend/src/pages/System/Tasks/table.tsx +++ b/frontend/src/pages/System/Tasks/table.tsx @@ -17,7 +17,7 @@ const Table: FunctionComponent<Props> = ({ tasks }) => { () => [ { header: "Name", - accessor: "name", + accessorKey: "name", cell: ({ row: { original: { name }, @@ -28,7 +28,7 @@ const Table: FunctionComponent<Props> = ({ tasks }) => { }, { header: "Interval", - accessor: "interval", + accessorKey: "interval", cell: ({ row: { original: { interval }, @@ -39,11 +39,11 @@ const Table: FunctionComponent<Props> = ({ tasks }) => { }, { header: "Next Execution", - accessor: "next_run_in", + accessorKey: "next_run_in", }, { header: "Run", - accessor: "job_running", + accessorKey: "job_running", cell: ({ row: { original: { job_id: jobId, job_running: jobRunning }, diff --git a/frontend/src/pages/Wanted/Movies/index.tsx b/frontend/src/pages/Wanted/Movies/index.tsx index 7c497f799..c05cfb7c3 100644 --- a/frontend/src/pages/Wanted/Movies/index.tsx +++ b/frontend/src/pages/Wanted/Movies/index.tsx @@ -21,7 +21,7 @@ const WantedMoviesView: FunctionComponent = () => { () => [ { header: "Name", - accessor: "title", + accessorKey: "title", cell: ({ row: { original: { title, radarrId }, @@ -37,7 +37,7 @@ const WantedMoviesView: FunctionComponent = () => { }, { header: "Missing", - accessor: "missing_subtitles", + accessorKey: "missing_subtitles", cell: ({ row: { original: { radarrId, missing_subtitles: missingSubtitles }, diff --git a/frontend/src/pages/views/ItemOverview.tsx b/frontend/src/pages/views/ItemOverview.tsx index 15b43aab1..36d296850 100644 --- a/frontend/src/pages/views/ItemOverview.tsx +++ b/frontend/src/pages/views/ItemOverview.tsx @@ -31,6 +31,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Language } from "@/components/bazarr"; import { BuildKey } from "@/utilities"; import { + normalizeAudioLanguage, useLanguageProfileBy, useProfileItemsToLanguages, } from "@/utilities/languages"; @@ -87,7 +88,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => { icon={faMusic} title="Audio Language" > - {v.name} + {normalizeAudioLanguage(v.name)} </ItemBadge> )) ?? [], [item?.audio_language], @@ -142,12 +143,7 @@ const ItemOverview: FunctionComponent<Props> = (props) => { }} > <Grid.Col span={3} visibleFrom="sm"> - <Image - src={item?.poster} - mx="auto" - maw="250px" - fallbackSrc="https://placehold.co/250x250?text=Placeholder" - ></Image> + <Image src={item?.poster} mx="auto" maw="250px"></Image> </Grid.Col> <Grid.Col span={8} maw="100%" style={{ overflow: "hidden" }}> <Stack align="flex-start" gap="xs" mx={6}> diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 069be3029..e8bd4483e 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -40,6 +40,7 @@ declare namespace Language { mustContain: string[]; mustNotContain: string[]; originalFormat: boolean | null; + tag: string | undefined; } } diff --git a/frontend/src/types/settings.d.ts b/frontend/src/types/settings.d.ts index 9ae6d8454..7b57f10cc 100644 --- a/frontend/src/types/settings.d.ts +++ b/frontend/src/types/settings.d.ts @@ -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; diff --git a/frontend/src/types/system.d.ts b/frontend/src/types/system.d.ts index 544d969ae..5a477fb54 100644 --- a/frontend/src/types/system.d.ts +++ b/frontend/src/types/system.d.ts @@ -20,6 +20,8 @@ declare namespace System { bazarr_config_directory: string; bazarr_directory: string; bazarr_version: string; + database_engine: string; + database_migration: string; operating_system: string; package_version: string; python_version: string; diff --git a/frontend/src/utilities/languages.ts b/frontend/src/utilities/languages.ts index 1b59aa4e7..7885e9667 100644 --- a/frontend/src/utilities/languages.ts +++ b/frontend/src/utilities/languages.ts @@ -51,3 +51,7 @@ export function useLanguageFromCode3(code3: string) { [data, code3], ); } + +export const normalizeAudioLanguage = (name: string) => { + return name === "Chinese Simplified" ? "Chinese" : name; +}; |