aboutsummaryrefslogtreecommitdiffhomepage
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/Router/index.tsx2
-rw-r--r--frontend/src/apis/hooks/episodes.ts26
-rw-r--r--frontend/src/apis/hooks/movies.ts33
-rw-r--r--frontend/src/apis/hooks/system.ts37
-rw-r--r--frontend/src/assets/badge.module.scss4
-rw-r--r--frontend/src/components/Search.tsx41
-rw-r--r--frontend/src/components/TextPopover.tsx2
-rw-r--r--frontend/src/components/async/MutateAction.tsx1
-rw-r--r--frontend/src/components/async/MutateButton.tsx1
-rw-r--r--frontend/src/components/async/QueryOverlay.tsx2
-rw-r--r--frontend/src/components/bazarr/AudioList.tsx3
-rw-r--r--frontend/src/components/forms/MovieUploadForm.tsx92
-rw-r--r--frontend/src/components/forms/ProfileEditForm.module.scss8
-rw-r--r--frontend/src/components/forms/ProfileEditForm.tsx31
-rw-r--r--frontend/src/components/forms/SeriesUploadForm.tsx103
-rw-r--r--frontend/src/components/forms/uploadFormSelectorTypes.tsx16
-rw-r--r--frontend/src/components/inputs/Selector.tsx5
-rw-r--r--frontend/src/modules/modals/hooks.ts13
-rw-r--r--frontend/src/modules/socketio/reducer.ts52
-rw-r--r--frontend/src/pages/Movies/index.tsx3
-rw-r--r--frontend/src/pages/Series/index.tsx31
-rw-r--r--frontend/src/pages/Settings/General/index.tsx4
-rw-r--r--frontend/src/pages/Settings/Languages/index.tsx46
-rw-r--r--frontend/src/pages/Settings/Languages/table.tsx49
-rw-r--r--frontend/src/pages/Settings/Providers/components.tsx2
-rw-r--r--frontend/src/pages/Settings/Providers/list.ts44
-rw-r--r--frontend/src/pages/Settings/Radarr/index.tsx5
-rw-r--r--frontend/src/pages/Settings/Sonarr/index.tsx5
-rw-r--r--frontend/src/pages/Settings/Subtitles/index.tsx50
-rw-r--r--frontend/src/pages/Settings/components/Card.tsx29
-rw-r--r--frontend/src/pages/Settings/components/forms.test.tsx8
-rw-r--r--frontend/src/pages/Settings/components/forms.tsx26
-rw-r--r--frontend/src/pages/System/Announcements/table.tsx8
-rw-r--r--frontend/src/pages/System/Status/index.tsx2
-rw-r--r--frontend/src/pages/System/Tasks/table.tsx8
-rw-r--r--frontend/src/pages/Wanted/Movies/index.tsx4
-rw-r--r--frontend/src/pages/views/ItemOverview.tsx10
-rw-r--r--frontend/src/types/api.d.ts1
-rw-r--r--frontend/src/types/settings.d.ts1
-rw-r--r--frontend/src/types/system.d.ts2
-rw-r--r--frontend/src/utilities/languages.ts4
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;
+};