+
diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx
index 71750678..18579d64 100644
--- a/src/components/RequestModal/TvRequestModal.tsx
+++ b/src/components/RequestModal/TvRequestModal.tsx
@@ -42,7 +42,6 @@ const messages = defineMessages('components.RequestModal', {
season: 'Season',
numberofepisodes: '# of Episodes',
seasonnumber: 'Season {number}',
- extras: 'Extras',
errorediting: 'Something went wrong while editing the request.',
requestedited: 'Request for
{title} edited successfully!',
requestApproved: 'Request for
{title} approved!',
@@ -254,11 +253,13 @@ const TvRequestModal = ({
};
const getAllSeasons = (): number[] => {
- return (data?.seasons ?? [])
- .filter(
- (season) => season.seasonNumber !== 0 && season.episodeCount !== 0
- )
- .map((season) => season.seasonNumber);
+ let allSeasons = (data?.seasons ?? []).filter(
+ (season) => season.episodeCount !== 0
+ );
+ if (!settings.currentSettings.partialRequestsEnabled) {
+ allSeasons = allSeasons.filter((season) => season.seasonNumber !== 0);
+ }
+ return allSeasons.map((season) => season.seasonNumber);
};
const getAllRequestedSeasons = (): number[] => {
@@ -582,7 +583,9 @@ const TvRequestModal = ({
{data?.seasons
.filter(
(season) =>
- season.seasonNumber !== 0 && season.episodeCount !== 0
+ (!settings.currentSettings.enableSpecialEpisodes
+ ? season.seasonNumber !== 0
+ : true) && season.episodeCount !== 0
)
.map((season) => {
const seasonRequest = getSeasonRequest(
@@ -660,7 +663,7 @@ const TvRequestModal = ({
{season.seasonNumber === 0
- ? intl.formatMessage(messages.extras)
+ ? intl.formatMessage(globalMessages.specials)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}
diff --git a/src/components/Selector/index.tsx b/src/components/Selector/index.tsx
index db959170..6c831909 100644
--- a/src/components/Selector/index.tsx
+++ b/src/components/Selector/index.tsx
@@ -13,6 +13,7 @@ import type {
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
+import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type {
Keyword,
ProductionCompany,
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStudios: 'Search studios…',
+ searchUsers: 'Select users…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
showmore: 'Show More',
@@ -374,7 +376,11 @@ export const WatchProviderSelector = ({
const { currentSettings } = useSettings();
const [showMore, setShowMore] = useState(false);
const [watchRegion, setWatchRegion] = useState(
- region ? region : currentSettings.region ? currentSettings.region : 'US'
+ region
+ ? region
+ : currentSettings.discoverRegion
+ ? currentSettings.discoverRegion
+ : 'US'
);
const [activeProvider, setActiveProvider] = useState(
activeProviders ?? []
@@ -437,7 +443,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
-
+
+
+
{isActive && (
@@ -483,7 +486,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
-
+
+
+
{isActive && (
@@ -548,3 +548,77 @@ export const WatchProviderSelector = ({
>
);
};
+
+export const UserSelector = ({
+ isMulti,
+ defaultValue,
+ onChange,
+}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
+ const intl = useIntl();
+ const [defaultDataValue, setDefaultDataValue] = useState<
+ { label: string; value: number }[] | null
+ >(null);
+
+ useEffect(() => {
+ const loadUsers = async (): Promise => {
+ if (!defaultValue) {
+ return;
+ }
+
+ const users = defaultValue.split(',');
+
+ const res = await fetch(`/api/v1/user`);
+ if (!res.ok) {
+ throw new Error('Network response was not ok');
+ }
+ const response: UserResultsResponse = await res.json();
+
+ const genreData = users
+ .filter((u) => response.results.find((user) => user.id === Number(u)))
+ .map((u) => response.results.find((user) => user.id === Number(u)))
+ .map((u) => ({
+ label: u?.displayName ?? '',
+ value: u?.id ?? 0,
+ }));
+
+ setDefaultDataValue(genreData);
+ };
+
+ loadUsers();
+ }, [defaultValue]);
+
+ const loadUserOptions = async (inputValue: string) => {
+ const res = await fetch(
+ `/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
+ );
+ if (!res.ok) throw new Error();
+ const results: UserResultsResponse = await res.json();
+
+ return results.results
+ .map((result) => ({
+ label: result.displayName,
+ value: result.id,
+ }))
+ .filter(({ label }) =>
+ label.toLowerCase().includes(inputValue.toLowerCase())
+ );
+ };
+
+ return (
+ {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onChange(value as any);
+ }}
+ />
+ );
+};
diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx
index b62263fb..82ac6840 100644
--- a/src/components/Settings/Notifications/NotificationsDiscord.tsx
+++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx
@@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookUrl: 'Webhook URL',
webhookUrlTip:
'Create a webhook integration in your server',
+ webhookRoleId: 'Notification Role ID',
+ webhookRoleIdTip:
+ 'The role ID to mention in the webhook message. Leave empty to disable mentions',
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…',
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
validationUrl: 'You must provide a valid URL',
+ validationWebhookRoleId: 'You must provide a valid Discord Role ID',
validationTypes: 'You must select at least one notification type',
enableMentions: 'Enable Mentions',
});
@@ -53,6 +57,12 @@ const NotificationsDiscord = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
+ webhookRoleId: Yup.string()
+ .nullable()
+ .matches(
+ /^\d{17,19}$/,
+ intl.formatMessage(messages.validationWebhookRoleId)
+ ),
});
if (!data && !error) {
@@ -67,6 +77,7 @@ const NotificationsDiscord = () => {
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl,
+ webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions,
}}
validationSchema={NotificationsDiscordSchema}
@@ -84,6 +95,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
+ webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -141,6 +153,7 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
+ webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -254,6 +267,21 @@ const NotificationsDiscord = () => {
)}
+
+
+
+
+
+
+ {errors.webhookRoleId &&
+ touched.webhookRoleId &&
+ typeof errors.webhookRoleId === 'string' && (
+ {errors.webhookRoleId}
+ )}
+
+
+
+
+
+
+
+
+ {errors.messageThreadId &&
+ touched.messageThreadId &&
+ typeof errors.messageThreadId === 'string' && (
+ {errors.messageThreadId}
+ )}
+
+
+
+ {intl.formatMessage(messages.overrideRules)}
+
+
+ {rules && (
+
+ )}
+ -
+
+
+
+
+
);
}}
diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx
index 316dc48e..7c6d02d8 100644
--- a/src/components/Settings/SettingsJellyfin.tsx
+++ b/src/components/Settings/SettingsJellyfin.tsx
@@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC = ({
),
jellyfinExternalUrl: Yup.string()
.nullable()
- .url(intl.formatMessage(messages.validationUrl))
+ .matches(
+ /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
+ intl.formatMessage(messages.validationUrl)
+ )
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC = ({
),
jellyfinForgotPasswordUrl: Yup.string()
.nullable()
- .url(intl.formatMessage(messages.validationUrl))
+ .matches(
+ /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
+ intl.formatMessage(messages.validationUrl)
+ )
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx
index 975de36c..aeba1531 100644
--- a/src/components/Settings/SettingsJobsCache/index.tsx
+++ b/src/components/Settings/SettingsJobsCache/index.tsx
@@ -58,6 +58,7 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages(
'plex-recently-added-scan': 'Plex Recently Added Scan',
'plex-full-scan': 'Plex Full Library Scan',
'plex-watchlist-sync': 'Plex Watchlist Sync',
+ 'plex-refresh-token': 'Plex Refresh Token',
'jellyfin-full-scan': 'Jellyfin Full Library Scan',
'jellyfin-recently-added-scan': 'Jellyfin Recently Added Scan',
'availability-sync': 'Media Availability Sync',
diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx
index 2d1e0219..8020b9fe 100644
--- a/src/components/Settings/SettingsMain/index.tsx
+++ b/src/components/Settings/SettingsMain/index.tsx
@@ -31,10 +31,12 @@ const messages = defineMessages('components.Settings.SettingsMain', {
apikey: 'API Key',
applicationTitle: 'Application Title',
applicationurl: 'Application URL',
- region: 'Discover Region',
- regionTip: 'Filter content by regional availability',
+ discoverRegion: 'Discover Region',
+ discoverRegionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
originallanguageTip: 'Filter content by original language',
+ streamingRegion: 'Streaming Region',
+ streamingRegionTip: 'Show streaming sites by regional availability',
toastApiKeySuccess: 'New API key generated successfully!',
toastApiKeyFailure: 'Something went wrong while generating a new API key.',
toastSettingsSuccess: 'Settings saved successfully!',
@@ -54,6 +56,7 @@ const messages = defineMessages('components.Settings.SettingsMain', {
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
partialRequestsEnabled: 'Allow Partial Series Requests',
+ enableSpecialEpisodes: 'Allow Special Episodes Requests',
locale: 'Display Language',
proxyEnabled: 'HTTP(S) Proxy',
proxyHostname: 'Proxy Hostname',
@@ -87,7 +90,10 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationTitle)
),
applicationUrl: Yup.string()
- .url(intl.formatMessage(messages.validationApplicationUrl))
+ .matches(
+ /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
+ intl.formatMessage(messages.validationApplicationUrl)
+ )
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -149,9 +155,11 @@ const SettingsMain = () => {
csrfProtection: data?.csrfProtection,
hideAvailable: data?.hideAvailable,
locale: data?.locale ?? 'en',
- region: data?.region,
+ discoverRegion: data?.discoverRegion,
originalLanguage: data?.originalLanguage,
+ streamingRegion: data?.streamingRegion,
partialRequestsEnabled: data?.partialRequestsEnabled,
+ enableSpecialEpisodes: data?.enableSpecialEpisodes,
trustProxy: data?.trustProxy,
cacheImages: data?.cacheImages,
proxyEnabled: data?.proxy?.enabled,
@@ -178,9 +186,11 @@ const SettingsMain = () => {
csrfProtection: values.csrfProtection,
hideAvailable: values.hideAvailable,
locale: values.locale,
- region: values.region,
+ discoverRegion: values.discoverRegion,
+ streamingRegion: values.streamingRegion,
originalLanguage: values.originalLanguage,
partialRequestsEnabled: values.partialRequestsEnabled,
+ enableSpecialEpisodes: values.enableSpecialEpisodes,
trustProxy: values.trustProxy,
cacheImages: values.cacheImages,
proxy: {
@@ -399,17 +409,17 @@ const SettingsMain = () => {
-
- {intl.formatMessage(messages.region)}
+
+ {intl.formatMessage(messages.discoverRegion)}
- {intl.formatMessage(messages.regionTip)}
+ {intl.formatMessage(messages.discoverRegionTip)}
@@ -431,6 +441,25 @@ const SettingsMain = () => {
+
+
+ {intl.formatMessage(messages.streamingRegion)}
+
+ {intl.formatMessage(messages.streamingRegionTip)}
+
+
+
+
@@ -472,6 +501,47 @@ const SettingsMain = () => {
/>
+
+
+
+ {intl.formatMessage(messages.enableSpecialEpisodes)}
+
+
+
+ {
+ setFieldValue(
+ 'enableSpecialEpisodes',
+ !values.enableSpecialEpisodes
+ );
+ }}
+ />
+
+
+
+
+
+
+
+
+
@@ -493,173 +563,161 @@ const SettingsMain = () => {
{values.proxyEnabled && (
<>
-
-
-
+
+
+
{intl.formatMessage(messages.proxyHostname)}
-
-
-
-
-
+
+
+
+
+
+ {errors.proxyHostname &&
+ touched.proxyHostname &&
+ typeof errors.proxyHostname === 'string' && (
+
+ {errors.proxyHostname}
+
+ )}
- {errors.proxyHostname &&
- touched.proxyHostname &&
- typeof errors.proxyHostname === 'string' && (
- {errors.proxyHostname}
- )}
-
-
-
-
+
+
{intl.formatMessage(messages.proxyPort)}
-
-
-
-
-
+
+
+
+
+
+ {errors.proxyPort &&
+ touched.proxyPort &&
+ typeof errors.proxyPort === 'string' && (
+ {errors.proxyPort}
+ )}
- {errors.proxyPort &&
- touched.proxyPort &&
- typeof errors.proxyPort === 'string' && (
- {errors.proxyPort}
- )}
-
-
-
-
+
+
{intl.formatMessage(messages.proxySsl)}
-
-
-
- {
- setFieldValue('proxySsl', !values.proxySsl);
- }}
- />
+
+
+ {
+ setFieldValue('proxySsl', !values.proxySsl);
+ }}
+ />
+
-
-
-
-
+
+
{intl.formatMessage(messages.proxyUser)}
-
-
-
-
-
+
+
+
+
+
+ {errors.proxyUser &&
+ touched.proxyUser &&
+ typeof errors.proxyUser === 'string' && (
+ {errors.proxyUser}
+ )}
- {errors.proxyUser &&
- touched.proxyUser &&
- typeof errors.proxyUser === 'string' && (
- {errors.proxyUser}
- )}
-
-
-
-
+
+
{intl.formatMessage(messages.proxyPassword)}
-
-
-
-
-
+
+
+
+
+
+ {errors.proxyPassword &&
+ touched.proxyPassword &&
+ typeof errors.proxyPassword === 'string' && (
+
+ {errors.proxyPassword}
+
+ )}
- {errors.proxyPassword &&
- touched.proxyPassword &&
- typeof errors.proxyPassword === 'string' && (
- {errors.proxyPassword}
- )}
-
-
-
-
+
+
{intl.formatMessage(messages.proxyBypassFilter)}
-
-
- {intl.formatMessage(messages.proxyBypassFilterTip)}
-
-
-
-
-
+
+ {intl.formatMessage(messages.proxyBypassFilterTip)}
+
+
+
+
+
+
+ {errors.proxyBypassFilter &&
+ touched.proxyBypassFilter &&
+ typeof errors.proxyBypassFilter === 'string' && (
+
+ {errors.proxyBypassFilter}
+
+ )}
- {errors.proxyBypassFilter &&
- touched.proxyBypassFilter &&
- typeof errors.proxyBypassFilter === 'string' && (
-
- {errors.proxyBypassFilter}
-
- )}
-
-
-
-
+
+
{intl.formatMessage(
messages.proxyBypassLocalAddresses
)}
-
-
-
- {
- setFieldValue(
- 'proxyBypassLocalAddresses',
- !values.proxyBypassLocalAddresses
- );
- }}
- />
+
+
+ {
+ setFieldValue(
+ 'proxyBypassLocalAddresses',
+ !values.proxyBypassLocalAddresses
+ );
+ }}
+ />
+
>
)}
-
-
-
-
-
-
-
);
}}
diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx
index a20fc483..82cabe92 100644
--- a/src/components/Settings/SettingsPlex.tsx
+++ b/src/components/Settings/SettingsPlex.tsx
@@ -190,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
otherwise: Yup.string().nullable(),
}),
tautulliExternalUrl: Yup.string()
- .url(intl.formatMessage(messages.validationUrl))
+ .matches(
+ /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
+ intl.formatMessage(messages.validationUrl)
+ )
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx
index 63cd463f..5e3871fc 100644
--- a/src/components/Settings/SettingsServices.tsx
+++ b/src/components/Settings/SettingsServices.tsx
@@ -6,12 +6,14 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
+import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
+import type OverrideRule from '@server/entity/OverrideRule';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { Fragment, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -57,6 +59,33 @@ interface ServerInstanceProps {
onDelete: () => void;
}
+export interface DVRTestResponse {
+ profiles: {
+ id: number;
+ name: string;
+ }[];
+ rootFolders: {
+ id: number;
+ path: string;
+ }[];
+ tags: {
+ id: number;
+ label: string;
+ }[];
+ urlBase?: string;
+}
+
+export type RadarrTestResponse = DVRTestResponse;
+
+export type SonarrTestResponse = DVRTestResponse & {
+ languageProfiles:
+ | {
+ id: number;
+ name: string;
+ }[]
+ | null;
+};
+
const ServerInstance = ({
name,
hostname,
@@ -193,6 +222,15 @@ const SettingsServices = () => {
type: 'radarr',
serverId: null,
});
+ const [overrideRuleModal, setOverrideRuleModal] = useState<{
+ open: boolean;
+ rule: OverrideRule | null;
+ testResponse: DVRTestResponse | null;
+ }>({
+ open: false,
+ rule: null,
+ testResponse: null,
+ });
const deleteServer = async () => {
const res = await fetch(
@@ -227,26 +265,51 @@ const SettingsServices = () => {
})}
+ {overrideRuleModal.open && overrideRuleModal.testResponse && (
+
+ setOverrideRuleModal({
+ open: false,
+ rule: null,
+ testResponse: null,
+ })
+ }
+ testResponse={overrideRuleModal.testResponse}
+ radarrId={editRadarrModal.radarr?.id}
+ sonarrId={editSonarrModal.sonarr?.id}
+ />
+ )}
{editRadarrModal.open && (
setEditRadarrModal({ open: false, radarr: null })}
+ onClose={() => {
+ if (!overrideRuleModal.open)
+ setEditRadarrModal({ open: false, radarr: null });
+ }}
onSave={() => {
revalidateRadarr();
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
+ overrideRuleModal={overrideRuleModal}
+ setOverrideRuleModal={setOverrideRuleModal}
/>
)}
{editSonarrModal.open && (
setEditSonarrModal({ open: false, sonarr: null })}
+ onClose={() => {
+ if (!overrideRuleModal.open)
+ setEditSonarrModal({ open: false, sonarr: null });
+ }}
onSave={() => {
revalidateSonarr();
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
+ overrideRuleModal={overrideRuleModal}
+ setOverrideRuleModal={setOverrideRuleModal}
/>
)}
void;
onSave: () => void;
+ overrideRuleModal: { open: boolean; rule: OverrideRule | null };
+ setOverrideRuleModal: ({
+ open,
+ rule,
+ testResponse,
+ }: {
+ open: boolean;
+ rule: OverrideRule | null;
+ testResponse: DVRTestResponse;
+ }) => void;
}
-const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
+const SonarrModal = ({
+ onClose,
+ sonarr,
+ onSave,
+ overrideRuleModal,
+ setOverrideRuleModal,
+}: SonarrModalProps) => {
const intl = useIntl();
+ const { data: rules, mutate: revalidate } =
+ useSWR('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
- const [testResponse, setTestResponse] = useState({
+ const [testResponse, setTestResponse] = useState({
profiles: [],
rootFolders: [],
languageProfiles: null,
tags: [],
});
+
const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
@@ -145,7 +154,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
)
: Yup.number(),
externalUrl: Yup.string()
- .url(intl.formatMessage(messages.validationApplicationUrl))
+ .matches(
+ /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
+ intl.formatMessage(messages.validationApplicationUrl)
+ )
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -194,7 +206,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}),
});
if (!res.ok) throw new Error();
- const data: TestResponse = await res.json();
+ const data: SonarrTestResponse = await res.json();
setIsValidated(true);
setTestResponse(data);
@@ -232,6 +244,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
}
}, [sonarr, testConnection]);
+ useEffect(() => {
+ revalidate();
+ }, [overrideRuleModal, revalidate]);
+
return (
{
values.is4k ? messages.edit4ksonarr : messages.editsonarr
)
}
+ backgroundClickable={!overrideRuleModal.open}
>
@@ -1053,6 +1070,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
+
+ {intl.formatMessage(messages.overrideRules)}
+
+
+ {rules && (
+
+ )}
+ -
+
+
+
+
+
);
}}
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx
index f4d058a8..77028595 100644
--- a/src/components/TvDetails/index.tsx
+++ b/src/components/TvDetails/index.tsx
@@ -222,15 +222,15 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
});
}
- const region = user?.settings?.region
- ? user.settings.region
- : settings.currentSettings.region
- ? settings.currentSettings.region
+ const discoverRegion = user?.settings?.discoverRegion
+ ? user.settings.discoverRegion
+ : settings.currentSettings.discoverRegion
+ ? settings.currentSettings.discoverRegion
: 'US';
const seriesAttributes: React.ReactNode[] = [];
const contentRating = data.contentRatings.results.find(
- (r) => r.iso_3166_1 === region
+ (r) => r.iso_3166_1 === discoverRegion
)?.rating;
if (contentRating) {
seriesAttributes.push(
@@ -238,6 +238,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
);
}
+ // Does NOT include "Specials"
const seasonCount = data.seasons.filter(
(season) => season.seasonNumber !== 0 && season.episodeCount !== 0
).length;
@@ -299,13 +300,29 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
return [...requestedSeasons, ...availableSeasons];
};
- const isComplete = seasonCount <= getAllRequestedSeasons(false).length;
+ const showHasSpecials = data.seasons.some(
+ (season) =>
+ season.seasonNumber === 0 &&
+ settings.currentSettings.partialRequestsEnabled
+ );
- const is4kComplete = seasonCount <= getAllRequestedSeasons(true).length;
+ const isComplete =
+ (showHasSpecials ? seasonCount + 1 : seasonCount) <=
+ getAllRequestedSeasons(false).length;
+ const is4kComplete =
+ (showHasSpecials ? seasonCount + 1 : seasonCount) <=
+ getAllRequestedSeasons(true).length;
+
+ const streamingRegion = user?.settings?.streamingRegion
+ ? user.settings.streamingRegion
+ : settings.currentSettings.streamingRegion
+ ? settings.currentSettings.streamingRegion
+ : 'US';
const streamingProviders =
- data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
- ?.flatrate ?? [];
+ data?.watchProviders?.find(
+ (provider) => provider.iso_3166_1 === streamingRegion
+ )?.flatrate ?? [];
function getAvalaibleMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
@@ -784,7 +801,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
{data.seasons
.slice()
.reverse()
- .filter((season) => season.seasonNumber !== 0)
+ .filter(
+ (season) =>
+ settings.currentSettings.enableSpecialEpisodes ||
+ season.seasonNumber !== 0
+ )
.map((season) => {
const show4k =
settings.currentSettings.series4kEnabled &&
@@ -838,9 +859,11 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
>
- {intl.formatMessage(messages.seasonnumber, {
- seasonNumber: season.seasonNumber,
- })}
+ {season.seasonNumber === 0
+ ? intl.formatMessage(globalMessages.specials)
+ : intl.formatMessage(messages.seasonnumber, {
+ seasonNumber: season.seasonNumber,
+ })}
{intl.formatMessage(messages.episodeCount, {
diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
index 502a9d84..4ee8a80f 100644
--- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
+++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx
@@ -44,10 +44,16 @@ const messages = defineMessages(
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
toastSettingsFailureEmail: 'This email is already taken!',
+ toastSettingsFailureEmailEmpty:
+ 'Another user already has this username. You must set an email',
region: 'Discover Region',
regionTip: 'Filter content by regional availability',
+ discoverRegion: 'Discover Region',
+ discoverRegionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
originallanguageTip: 'Filter content by original language',
+ streamingRegion: 'Streaming Region',
+ streamingRegionTip: 'Show streaming sites by regional availability',
movierequestlimit: 'Movie Request Limit',
seriesrequestlimit: 'Series Request Limit',
enableOverride: 'Override Global Limit',
@@ -138,11 +144,12 @@ const UserGeneralSettings = () => {
{
values.email || user?.jellyfinUsername || user?.plexUsername,
discordId: values.discordId,
locale: values.locale,
- region: values.region,
+ discoverRegion: values.discoverRegion,
+ streamingRegion: values.streamingRegion,
originalLanguage: values.originalLanguage,
movieQuotaLimit: movieQuotaEnabled
? values.movieQuotaLimit
@@ -203,10 +211,23 @@ const UserGeneralSettings = () => {
/* empty */
}
if (errorData?.message === ApiErrorCode.InvalidEmail) {
- addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
- autoDismiss: true,
- appearance: 'error',
- });
+ if (values.email) {
+ addToast(
+ intl.formatMessage(messages.toastSettingsFailureEmail),
+ {
+ autoDismiss: true,
+ appearance: 'error',
+ }
+ );
+ } else {
+ addToast(
+ intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
+ {
+ autoDismiss: true,
+ appearance: 'error',
+ }
+ );
+ }
} else {
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
@@ -284,9 +305,9 @@ const UserGeneralSettings = () => {
name="displayName"
type="text"
placeholder={
- user?.username ||
user?.jellyfinUsername ||
- user?.plexUsername
+ user?.plexUsername ||
+ user?.email
}
/>
@@ -385,17 +406,17 @@ const UserGeneralSettings = () => {
-
- {intl.formatMessage(messages.region)}
+
+ {intl.formatMessage(messages.discoverRegion)}
- {intl.formatMessage(messages.regionTip)}
+ {intl.formatMessage(messages.discoverRegionTip)}
@@ -420,6 +441,26 @@ const UserGeneralSettings = () => {
+
+
+ {intl.formatMessage(messages.streamingRegion)}
+
+ {intl.formatMessage(messages.streamingRegionTip)}
+
+
+
+
{currentHasPermission(Permission.MANAGE_USERS) &&
!hasPermission(Permission.MANAGE_USERS) && (
<>
diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx
index 2826711e..92ce5d15 100644
--- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx
+++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx
@@ -21,9 +21,14 @@ const messages = defineMessages(
telegramChatId: 'Chat ID',
telegramChatIdTipLong:
' Start a chat, add @get_id_bot, and issue the /my_id command',
+ telegramMessageThreadId: 'Thread/Topic ID',
+ telegramMessageThreadIdTip:
+ "If your group-chat has topics enabled, you can specify a thread/topic's ID here",
sendSilently: 'Send Silently',
sendSilentlyDescription: 'Send notifications with no sound',
validationTelegramChatId: 'You must provide a valid chat ID',
+ validationTelegramMessageThreadId:
+ 'The thread/topic ID must be a positive whole number',
}
);
@@ -53,6 +58,20 @@ const UserTelegramSettings = () => {
/^-?\d+$/,
intl.formatMessage(messages.validationTelegramChatId)
),
+ telegramMessageThreadId: Yup.string()
+ .when(['types'], {
+ is: (enabled: boolean, types: number) => enabled && !!types,
+ then: Yup.string()
+ .nullable()
+ .required(
+ intl.formatMessage(messages.validationTelegramMessageThreadId)
+ ),
+ otherwise: Yup.string().nullable(),
+ })
+ .matches(
+ /^\d+$/,
+ intl.formatMessage(messages.validationTelegramMessageThreadId)
+ ),
});
if (!data && !error) {
@@ -63,6 +82,7 @@ const UserTelegramSettings = () => {
{
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,
telegramChatId: values.telegramChatId,
+ telegramMessageThreadId: values.telegramMessageThreadId,
telegramSendSilently: values.telegramSendSilently,
notificationTypes: {
telegram: values.types,
@@ -162,6 +183,30 @@ const UserTelegramSettings = () => {
)}
+
+
+ {intl.formatMessage(messages.telegramMessageThreadId)}
+
+ {intl.formatMessage(messages.telegramMessageThreadIdTip)}
+
+
+
+
+
+
+ {errors.telegramMessageThreadId &&
+ touched.telegramMessageThreadId &&
+ typeof errors.telegramMessageThreadId === 'string' && (
+
+ {errors.telegramMessageThreadId}
+
+ )}
+
+
{intl.formatMessage(messages.sendSilently)}
diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx
index 10a658db..cb4338aa 100644
--- a/src/context/LanguageContext.tsx
+++ b/src/context/LanguageContext.tsx
@@ -31,6 +31,7 @@ export type AvailableLocale =
| 'sq'
| 'sr'
| 'sv'
+ | 'tr'
| 'uk'
| 'zh-CN'
| 'zh-TW';
@@ -149,6 +150,10 @@ export const availableLanguages: AvailableLanguageObject = {
code: 'sr',
display: 'српски језик',
},
+ tr: {
+ code: 'tr',
+ display: 'Türkçe',
+ },
ar: {
code: 'ar',
display: 'العربية',
diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx
index 7be39396..6a286d8a 100644
--- a/src/context/SettingsContext.tsx
+++ b/src/context/SettingsContext.tsx
@@ -16,10 +16,12 @@ const defaultSettings = {
localLogin: true,
movie4kEnabled: false,
series4kEnabled: false,
- region: '',
+ discoverRegion: '',
+ streamingRegion: '',
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
+ enableSpecialEpisodes: false,
cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
diff --git a/src/hooks/useDeepLinks.ts b/src/hooks/useDeepLinks.ts
index 98308659..bc367229 100644
--- a/src/hooks/useDeepLinks.ts
+++ b/src/hooks/useDeepLinks.ts
@@ -23,7 +23,7 @@ const useDeepLinks = ({
if (
settings.currentSettings.mediaServerType === MediaServerType.PLEX &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) ||
- (navigator.userAgent === 'MacIntel' && navigator.maxTouchPoints > 1))
+ (navigator.userAgent.includes('Mac') && navigator.maxTouchPoints > 1))
) {
setReturnedMediaUrl(iOSPlexUrl);
setReturnedMediaUrl4k(iOSPlexUrl4k);
diff --git a/src/hooks/useDiscover.ts b/src/hooks/useDiscover.ts
index 2a2acd02..8e53494f 100644
--- a/src/hooks/useDiscover.ts
+++ b/src/hooks/useDiscover.ts
@@ -10,6 +10,7 @@ export interface BaseSearchResult {
}
interface BaseMedia {
+ id: number;
mediaType: string;
mediaInfo?: {
status: MediaStatus;
@@ -82,6 +83,8 @@ const useDiscover = <
}
);
+ const resultIds: Set = new Set();
+
const isLoadingInitialData = !data && !error;
const isLoadingMore =
isLoadingInitialData ||
@@ -94,7 +97,18 @@ const useDiscover = <
setSize(size + 1);
};
- let titles = (data ?? []).reduce((a, v) => [...a, ...v.results], [] as T[]);
+ let titles = (data ?? []).reduce((a, v) => {
+ const results: T[] = [];
+
+ for (const result of v.results) {
+ if (!resultIds.has(result.id)) {
+ resultIds.add(result.id);
+ results.push(result);
+ }
+ }
+
+ return [...a, ...results];
+ }, [] as T[]);
if (settings.currentSettings.hideAvailable && hideAvailable) {
titles = titles.filter(
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts
index f1e83006..f60b402c 100644
--- a/src/hooks/useUser.ts
+++ b/src/hooks/useUser.ts
@@ -29,7 +29,8 @@ type NotificationAgentTypes = Record;
export interface UserSettings {
discordId?: string;
- region?: string;
+ discoverRegion?: string;
+ streamingRegion?: string;
originalLanguage?: string;
locale?: string;
notificationTypes: Partial;
diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts
index 6aa5ed1d..1765f193 100644
--- a/src/i18n/globalMessages.ts
+++ b/src/i18n/globalMessages.ts
@@ -65,6 +65,7 @@ const globalMessages = defineMessages('i18n', {
'{title} was successfully removed from the Blacklist.',
addToBlacklist: 'Add to Blacklist',
removefromBlacklist: 'Remove from Blacklist',
+ specials: 'Specials',
});
export default globalMessages;
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json
index f52fe844..ded40f9f 100644
--- a/src/i18n/locale/en.json
+++ b/src/i18n/locale/en.json
@@ -100,6 +100,7 @@
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
+ "components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover",
@@ -503,6 +504,7 @@
"components.RequestList.requests": "Requests",
"components.RequestList.showallrequests": "Show All Requests",
"components.RequestList.sortAdded": "Most Recent",
+ "components.RequestList.sortDirection": "Toggle Sort Direction",
"components.RequestList.sortModified": "Last Modified",
"components.RequestModal.AdvancedRequester.advancedoptions": "Advanced",
"components.RequestModal.AdvancedRequester.animenote": "* This series is an anime.",
@@ -536,7 +538,6 @@
"components.RequestModal.cancel": "Cancel Request",
"components.RequestModal.edit": "Edit Request",
"components.RequestModal.errorediting": "Something went wrong while editing the request.",
- "components.RequestModal.extras": "Extras",
"components.RequestModal.numberofepisodes": "# of Episodes",
"components.RequestModal.pending4krequest": "Pending 4K Request",
"components.RequestModal.pendingapproval": "Your request is pending approval.",
@@ -589,6 +590,7 @@
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…",
+ "components.Selector.searchUsers": "Select users…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.",
@@ -697,6 +699,8 @@
"components.Settings.Notifications.encryptionNone": "None",
"components.Settings.Notifications.encryptionOpportunisticTls": "Always use STARTTLS",
"components.Settings.Notifications.encryptionTip": "In most cases, Implicit TLS uses port 465 and STARTTLS uses port 587",
+ "components.Settings.Notifications.messageThreadId": "Thread/Topic ID",
+ "components.Settings.Notifications.messageThreadIdTip": "If your group-chat has topics enabled, you can specify a thread/topic's ID here",
"components.Settings.Notifications.pgpPassword": "PGP Password",
"components.Settings.Notifications.pgpPasswordTip": "Sign encrypted email messages using OpenPGP",
"components.Settings.Notifications.pgpPrivateKey": "PGP Private Key",
@@ -721,15 +725,49 @@
"components.Settings.Notifications.validationBotAPIRequired": "You must provide a bot authorization token",
"components.Settings.Notifications.validationChatIdRequired": "You must provide a valid chat ID",
"components.Settings.Notifications.validationEmail": "You must provide a valid email address",
+ "components.Settings.Notifications.validationMessageThreadId": "The thread/topic ID must be a positive whole number",
"components.Settings.Notifications.validationPgpPassword": "You must provide a PGP password",
"components.Settings.Notifications.validationPgpPrivateKey": "You must provide a valid PGP private key",
"components.Settings.Notifications.validationSmtpHostRequired": "You must provide a valid hostname or IP address",
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
"components.Settings.Notifications.validationTypes": "You must select at least one notification type",
"components.Settings.Notifications.validationUrl": "You must provide a valid URL",
+ "components.Settings.Notifications.validationWebhookRoleId": "You must provide a valid Discord Role ID",
+ "components.Settings.Notifications.webhookRoleId": "Notification Role ID",
+ "components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
"components.Settings.Notifications.webhookUrl": "Webhook URL",
"components.Settings.Notifications.webhookUrlTip": "Create a webhook integration in your server",
+ "components.Settings.OverrideRuleModal.conditions": "Conditions",
+ "components.Settings.OverrideRuleModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).",
+ "components.Settings.OverrideRuleModal.create": "Create rule",
+ "components.Settings.OverrideRuleModal.createrule": "New Override Rule",
+ "components.Settings.OverrideRuleModal.editrule": "Edit Override Rule",
+ "components.Settings.OverrideRuleModal.genres": "Genres",
+ "components.Settings.OverrideRuleModal.keywords": "Keywords",
+ "components.Settings.OverrideRuleModal.languages": "Languages",
+ "components.Settings.OverrideRuleModal.notagoptions": "No tags.",
+ "components.Settings.OverrideRuleModal.qualityprofile": "Quality Profile",
+ "components.Settings.OverrideRuleModal.rootfolder": "Root Folder",
+ "components.Settings.OverrideRuleModal.ruleCreated": "Override rule created successfully!",
+ "components.Settings.OverrideRuleModal.ruleUpdated": "Override rule updated successfully!",
+ "components.Settings.OverrideRuleModal.selectQualityProfile": "Select quality profile",
+ "components.Settings.OverrideRuleModal.selectRootFolder": "Select root folder",
+ "components.Settings.OverrideRuleModal.selecttags": "Select tags",
+ "components.Settings.OverrideRuleModal.settings": "Settings",
+ "components.Settings.OverrideRuleModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
+ "components.Settings.OverrideRuleModal.tags": "Tags",
+ "components.Settings.OverrideRuleModal.users": "Users",
+ "components.Settings.OverrideRuleTile.conditions": "Conditions",
+ "components.Settings.OverrideRuleTile.genre": "Genre",
+ "components.Settings.OverrideRuleTile.keywords": "Keywords",
+ "components.Settings.OverrideRuleTile.language": "Language",
+ "components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
+ "components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
+ "components.Settings.OverrideRuleTile.settings": "Settings",
+ "components.Settings.OverrideRuleTile.tags": "Tags",
+ "components.Settings.OverrideRuleTile.users": "Users",
"components.Settings.RadarrModal.add": "Add Server",
+ "components.Settings.RadarrModal.addrule": "New Override Rule",
"components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key",
"components.Settings.RadarrModal.baseUrl": "URL Base",
@@ -748,6 +786,7 @@
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.",
+ "components.Settings.RadarrModal.overrideRules": "Override Rules",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released",
@@ -844,6 +883,7 @@
"components.Settings.SettingsJobsCache.nextexecution": "Next Execution",
"components.Settings.SettingsJobsCache.plex-full-scan": "Plex Full Library Scan",
"components.Settings.SettingsJobsCache.plex-recently-added-scan": "Plex Recently Added Scan",
+ "components.Settings.SettingsJobsCache.plex-refresh-token": "Plex Refresh Token",
"components.Settings.SettingsJobsCache.plex-watchlist-sync": "Plex Watchlist Sync",
"components.Settings.SettingsJobsCache.process": "Process",
"components.Settings.SettingsJobsCache.radarr-scan": "Radarr Scan",
@@ -877,6 +917,9 @@
"components.Settings.SettingsMain.csrfProtection": "Enable CSRF Protection",
"components.Settings.SettingsMain.csrfProtectionHoverTip": "Do NOT enable this setting unless you understand what you are doing!",
"components.Settings.SettingsMain.csrfProtectionTip": "Set external API access to read-only (requires HTTPS)",
+ "components.Settings.SettingsMain.discoverRegion": "Discover Region",
+ "components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability",
+ "components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests",
"components.Settings.SettingsMain.general": "General",
"components.Settings.SettingsMain.generalsettings": "General Settings",
"components.Settings.SettingsMain.generalsettingsDescription": "Configure global and default settings for Jellyseerr.",
@@ -894,8 +937,8 @@
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
- "components.Settings.SettingsMain.region": "Discover Region",
- "components.Settings.SettingsMain.regionTip": "Filter content by regional availability",
+ "components.Settings.SettingsMain.streamingRegion": "Streaming Region",
+ "components.Settings.SettingsMain.streamingRegionTip": "Show streaming sites by regional availability",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
"components.Settings.SettingsMain.toastApiKeySuccess": "New API key generated successfully!",
"components.Settings.SettingsMain.toastSettingsFailure": "Something went wrong while saving settings.",
@@ -920,6 +963,7 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
+ "components.Settings.SonarrModal.addrule": "New Override Rule",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
@@ -942,6 +986,7 @@
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.",
+ "components.Settings.SonarrModal.overrideRules": "Override Rules",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",
@@ -1094,7 +1139,7 @@
"components.Setup.finishing": "Finishing…",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
- "components.Setup.signin": "Sign in to your account",
+ "components.Setup.signin": "Sign In",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
@@ -1220,6 +1265,8 @@
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
"components.UserProfile.UserSettings.UserGeneralSettings.discordId": "Discord User ID",
"components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "The multi-digit ID number associated with your Discord user account",
+ "components.UserProfile.UserSettings.UserGeneralSettings.discoverRegion": "Discover Region",
+ "components.UserProfile.UserSettings.UserGeneralSettings.discoverRegionTip": "Filter content by regional availability",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",
"components.UserProfile.UserSettings.UserGeneralSettings.email": "Email",
"components.UserProfile.UserSettings.UserGeneralSettings.enableOverride": "Override Global Limit",
@@ -1243,8 +1290,11 @@
"components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes",
"components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…",
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
+ "components.UserProfile.UserSettings.UserGeneralSettings.streamingRegion": "Streaming Region",
+ "components.UserProfile.UserSettings.UserGeneralSettings.streamingRegionTip": "Show streaming sites by regional availability",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
+ "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
@@ -1277,6 +1327,8 @@
"components.UserProfile.UserSettings.UserNotificationSettings.sound": "Notification Sound",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramChatId": "Chat ID",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramChatIdTipLong": "Start a chat, add @get_id_bot, and issue the /my_id command",
+ "components.UserProfile.UserSettings.UserNotificationSettings.telegramMessageThreadId": "Thread/Topic ID",
+ "components.UserProfile.UserSettings.UserNotificationSettings.telegramMessageThreadIdTip": "If your group-chat has topics enabled, you can specify a thread/topic's ID here",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Telegram notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Telegram notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID",
@@ -1285,6 +1337,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "You must provide a valid application token",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverUserKey": "You must provide a valid user or group key",
"components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramChatId": "You must provide a valid chat ID",
+ "components.UserProfile.UserSettings.UserNotificationSettings.validationTelegramMessageThreadId": "The thread/topic ID must be a positive whole number",
"components.UserProfile.UserSettings.UserNotificationSettings.webpush": "Web Push",
"components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingsfailed": "Web push notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.webpushsettingssaved": "Web push notification settings saved successfully!",
@@ -1377,6 +1430,7 @@
"i18n.saving": "Saving…",
"i18n.settings": "Settings",
"i18n.showingresults": "Showing {from} to {to} of {total} results",
+ "i18n.specials": "Specials",
"i18n.status": "Status",
"i18n.test": "Test",
"i18n.testing": "Testing…",
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
index e5704052..facb3a44 100644
--- a/src/pages/_app.tsx
+++ b/src/pages/_app.tsx
@@ -85,6 +85,8 @@ const loadLocaleData = (locale: AvailableLocale): Promise => {
return import('../i18n/locale/sr.json');
case 'sv':
return import('../i18n/locale/sv.json');
+ case 'tr':
+ return import('../i18n/locale/tr.json');
case 'uk':
return import('../i18n/locale/uk.json');
case 'zh-CN':
@@ -192,10 +194,12 @@ CoreApp.getInitialProps = async (initialProps) => {
movie4kEnabled: false,
series4kEnabled: false,
localLogin: true,
- region: '',
+ discoverRegion: '',
+ streamingRegion: '',
originalLanguage: '',
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
+ enableSpecialEpisodes: false,
cacheImages: false,
vapidPublic: '',
enablePushRegistration: false,
diff --git a/src/utils/defineMessages.ts b/src/utils/defineMessages.ts
index 69a04a7d..bb8fadb4 100644
--- a/src/utils/defineMessages.ts
+++ b/src/utils/defineMessages.ts
@@ -1,18 +1,26 @@
import { defineMessages as intlDefineMessages } from 'react-intl';
-export default function defineMessages(
+type Messages> = {
+ [K in keyof T]: {
+ id: string;
+ defaultMessage: T[K];
+ };
+};
+
+export default function defineMessages>(
prefix: string,
- messages: Record
-) {
- const modifiedMessages: Record<
- string,
- { id: string; defaultMessage: string }
- > = {};
- for (const key of Object.keys(messages)) {
- modifiedMessages[key] = {
- id: prefix + '.' + key,
+ messages: T
+): Messages {
+ const keys: (keyof T)[] = Object.keys(messages);
+ const modifiedMessagesEntries = keys.map((key) => [
+ key,
+ {
+ id: `${prefix}.${key as string}`,
defaultMessage: messages[key],
- };
- }
+ },
+ ]);
+ const modifiedMessages: Messages = Object.fromEntries(
+ modifiedMessagesEntries
+ );
return intlDefineMessages(modifiedMessages);
}
|