diff --git a/frontend/src/Parse/Parse.css b/frontend/src/Parse/Parse.css
new file mode 100644
index 000000000..43536452c
--- /dev/null
+++ b/frontend/src/Parse/Parse.css
@@ -0,0 +1,45 @@
+.inputContainer {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.inputIconContainer {
+ width: 58px;
+ height: 46px;
+ border: 1px solid var(--inputBorderColor);
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: var(--inputIconContainerBackgroundColor);
+ text-align: center;
+ line-height: 46px;
+}
+
+.input {
+ composes: input from '~Components/Form/TextInput.css';
+
+ height: 46px;
+ border-radius: 0;
+ font-size: 18px;
+}
+
+.clearButton {
+ border: 1px solid var(--inputBorderColor);
+ border-left: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.message {
+ margin-top: 30px;
+ text-align: center;
+ font-weight: 300;
+ font-size: $largeFontSize;
+}
+
+.helpText {
+ margin-bottom: 10px;
+ font-size: 24px;
+}
diff --git a/frontend/src/Parse/Parse.css.d.ts b/frontend/src/Parse/Parse.css.d.ts
new file mode 100644
index 000000000..4a4def577
--- /dev/null
+++ b/frontend/src/Parse/Parse.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'clearButton': string;
+ 'helpText': string;
+ 'input': string;
+ 'inputContainer': string;
+ 'inputIconContainer': string;
+ 'message': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Parse/Parse.tsx b/frontend/src/Parse/Parse.tsx
new file mode 100644
index 000000000..e14babdbf
--- /dev/null
+++ b/frontend/src/Parse/Parse.tsx
@@ -0,0 +1,111 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import TextInput from 'Components/Form/TextInput';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import { icons } from 'Helpers/Props';
+import { clear, fetch } from 'Store/Actions/parseActions';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import ParseResult from './ParseResult';
+import parseStateSelector from './parseStateSelector';
+import styles from './Parse.css';
+
+function Parse() {
+ const { isFetching, error, item } = useSelector(parseStateSelector());
+
+ const [title, setTitle] = useState('');
+ const dispatch = useDispatch();
+
+ const onInputChange = useCallback(
+ ({ value }: { value: string }) => {
+ const trimmedValue = value.trim();
+
+ setTitle(value);
+
+ if (trimmedValue === '') {
+ dispatch(clear());
+ } else {
+ dispatch(fetch({ title: trimmedValue }));
+ }
+ },
+ [setTitle, dispatch]
+ );
+
+ const onClearPress = useCallback(() => {
+ setTitle('');
+ dispatch(clear());
+ }, [setTitle, dispatch]);
+
+ useEffect(
+ () => {
+ return () => {
+ dispatch(clear());
+ };
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ return (
+
+
+
+
+ {isFetching ? : null}
+
+ {!isFetching && !!error ? (
+
+
+ Error parsing, please try again.
+
+
{getErrorMessage(error)}
+
+ ) : null}
+
+ {!isFetching && title && !error && !item.parsedEpisodeInfo ? (
+
+ Unable to parse the provided title, please try again.
+
+ ) : null}
+
+ {!isFetching && !error && item.parsedEpisodeInfo ? (
+
+ ) : null}
+
+ {title ? null : (
+
+
+ Enter a release title in the input above
+
+
+ Sonarr will attempt to parse the title and show you details about
+ it
+
+
+ )}
+
+
+ );
+}
+
+export default Parse;
diff --git a/frontend/src/Parse/ParseModal.tsx b/frontend/src/Parse/ParseModal.tsx
new file mode 100644
index 000000000..0ee455bf0
--- /dev/null
+++ b/frontend/src/Parse/ParseModal.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Modal from 'Components/Modal/Modal';
+import ParseModalContent from './ParseModalContent';
+
+interface ParseModalProps {
+ isOpen: boolean;
+ onModalClose: () => void;
+}
+
+function ParseModal(props: ParseModalProps) {
+ const { isOpen, onModalClose } = props;
+
+ return (
+
+
+
+ );
+}
+
+export default ParseModal;
diff --git a/frontend/src/Parse/ParseModalContent.css b/frontend/src/Parse/ParseModalContent.css
new file mode 100644
index 000000000..43536452c
--- /dev/null
+++ b/frontend/src/Parse/ParseModalContent.css
@@ -0,0 +1,45 @@
+.inputContainer {
+ display: flex;
+ margin-bottom: 10px;
+}
+
+.inputIconContainer {
+ width: 58px;
+ height: 46px;
+ border: 1px solid var(--inputBorderColor);
+ border-right: none;
+ border-radius: 4px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ background-color: var(--inputIconContainerBackgroundColor);
+ text-align: center;
+ line-height: 46px;
+}
+
+.input {
+ composes: input from '~Components/Form/TextInput.css';
+
+ height: 46px;
+ border-radius: 0;
+ font-size: 18px;
+}
+
+.clearButton {
+ border: 1px solid var(--inputBorderColor);
+ border-left: none;
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
+}
+
+.message {
+ margin-top: 30px;
+ text-align: center;
+ font-weight: 300;
+ font-size: $largeFontSize;
+}
+
+.helpText {
+ margin-bottom: 10px;
+ font-size: 24px;
+}
diff --git a/frontend/src/Parse/ParseModalContent.css.d.ts b/frontend/src/Parse/ParseModalContent.css.d.ts
new file mode 100644
index 000000000..4a4def577
--- /dev/null
+++ b/frontend/src/Parse/ParseModalContent.css.d.ts
@@ -0,0 +1,12 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'clearButton': string;
+ 'helpText': string;
+ 'input': string;
+ 'inputContainer': string;
+ 'inputIconContainer': string;
+ 'message': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Parse/ParseModalContent.tsx b/frontend/src/Parse/ParseModalContent.tsx
new file mode 100644
index 000000000..cdff08376
--- /dev/null
+++ b/frontend/src/Parse/ParseModalContent.tsx
@@ -0,0 +1,125 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import TextInput from 'Components/Form/TextInput';
+import Icon from 'Components/Icon';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import ModalBody from 'Components/Modal/ModalBody';
+import ModalContent from 'Components/Modal/ModalContent';
+import ModalFooter from 'Components/Modal/ModalFooter';
+import ModalHeader from 'Components/Modal/ModalHeader';
+import { icons } from 'Helpers/Props';
+import { clear, fetch } from 'Store/Actions/parseActions';
+import getErrorMessage from 'Utilities/Object/getErrorMessage';
+import translate from 'Utilities/String/translate';
+import ParseResult from './ParseResult';
+import parseStateSelector from './parseStateSelector';
+import styles from './ParseModalContent.css';
+
+interface ParseModalContentProps {
+ onModalClose: () => void;
+}
+
+function ParseModalContent(props: ParseModalContentProps) {
+ const { onModalClose } = props;
+ const { isFetching, error, item } = useSelector(parseStateSelector());
+
+ const [title, setTitle] = useState('');
+ const dispatch = useDispatch();
+
+ const onInputChange = useCallback(
+ ({ value }: { value: string }) => {
+ const trimmedValue = value.trim();
+
+ setTitle(value);
+
+ if (trimmedValue === '') {
+ dispatch(clear());
+ } else {
+ dispatch(fetch({ title: trimmedValue }));
+ }
+ },
+ [setTitle, dispatch]
+ );
+
+ const onClearPress = useCallback(() => {
+ setTitle('');
+ dispatch(clear());
+ }, [setTitle, dispatch]);
+
+ useEffect(
+ () => {
+ return () => {
+ dispatch(clear());
+ };
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ );
+
+ return (
+
+ {translate('TestParsing')}
+
+
+
+
+ {isFetching ? : null}
+
+ {!isFetching && !!error ? (
+
+
+ Error parsing, please try again.
+
+
{getErrorMessage(error)}
+
+ ) : null}
+
+ {!isFetching && title && !error && !item.parsedEpisodeInfo ? (
+
+ Unable to parse the provided title, please try again.
+
+ ) : null}
+
+ {!isFetching && !error && item.parsedEpisodeInfo ? (
+
+ ) : null}
+
+ {title ? null : (
+
+
+ Enter a release title in the input above
+
+
+ Sonarr will attempt to parse the title and show you details about
+ it
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default ParseModalContent;
diff --git a/frontend/src/Parse/ParseResult.css b/frontend/src/Parse/ParseResult.css
new file mode 100644
index 000000000..c49c4e3fa
--- /dev/null
+++ b/frontend/src/Parse/ParseResult.css
@@ -0,0 +1,8 @@
+.container {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.column {
+ flex: 0 0 50%;
+}
diff --git a/frontend/src/Parse/ParseResult.css.d.ts b/frontend/src/Parse/ParseResult.css.d.ts
new file mode 100644
index 000000000..653368e06
--- /dev/null
+++ b/frontend/src/Parse/ParseResult.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'column': string;
+ 'container': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Parse/ParseResult.tsx b/frontend/src/Parse/ParseResult.tsx
new file mode 100644
index 000000000..e5dafc240
--- /dev/null
+++ b/frontend/src/Parse/ParseResult.tsx
@@ -0,0 +1,243 @@
+import React from 'react';
+import { ParseModel } from 'App/State/ParseAppState';
+import FieldSet from 'Components/FieldSet';
+import EpisodeFormats from 'Episode/EpisodeFormats';
+import SeriesTitleLink from 'Series/SeriesTitleLink';
+import translate from 'Utilities/String/translate';
+import ParseResultItem from './ParseResultItem';
+import styles from './ParseResult.css';
+
+interface ParseResultProps {
+ item: ParseModel;
+}
+
+function ParseResult(props: ParseResultProps) {
+ const { item } = props;
+ const {
+ customFormats,
+ customFormatScore,
+ episodes,
+ languages,
+ parsedEpisodeInfo,
+ series,
+ } = item;
+
+ const {
+ releaseTitle,
+ seriesTitle,
+ seriesTitleInfo,
+ releaseGroup,
+ releaseHash,
+ seasonNumber,
+ episodeNumbers,
+ absoluteEpisodeNumbers,
+ special,
+ fullSeason,
+ isMultiSeason,
+ isPartialSeason,
+ isDaily,
+ airDate,
+ quality,
+ } = parsedEpisodeInfo;
+
+ const finalLanguages = languages ?? parsedEpisodeInfo.languages;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ '-'
+ )
+ }
+ />
+
+
}
+ />
+
+
+
+
+ );
+}
+
+export default ParseResult;
diff --git a/frontend/src/Parse/ParseResultItem.css b/frontend/src/Parse/ParseResultItem.css
new file mode 100644
index 000000000..275fe7e1f
--- /dev/null
+++ b/frontend/src/Parse/ParseResultItem.css
@@ -0,0 +1,21 @@
+.item {
+ display: flex;
+}
+
+.title {
+ margin-right: 20px;
+ width: 250px;
+ text-align: right;
+ font-weight: bold;
+}
+
+@media (max-width: $breakpointSmall) {
+ .item {
+ display: block;
+ margin-bottom: 10px;
+ }
+
+ .title {
+ text-align: left;
+ }
+}
diff --git a/frontend/src/Parse/ParseResultItem.css.d.ts b/frontend/src/Parse/ParseResultItem.css.d.ts
new file mode 100644
index 000000000..bcf268e50
--- /dev/null
+++ b/frontend/src/Parse/ParseResultItem.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'item': string;
+ 'title': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Parse/ParseResultItem.tsx b/frontend/src/Parse/ParseResultItem.tsx
new file mode 100644
index 000000000..661af448d
--- /dev/null
+++ b/frontend/src/Parse/ParseResultItem.tsx
@@ -0,0 +1,20 @@
+import React, { ReactNode } from 'react';
+import styles from './ParseResultItem.css';
+
+interface ParseResultItemProps {
+ title: string;
+ data: string | number | ReactNode;
+}
+
+function ParseResultItem(props: ParseResultItemProps) {
+ const { title, data } = props;
+
+ return (
+