New: Added UI for parsing release names

Closes #5263
This commit is contained in:
Mark McDowall 2023-05-07 18:55:00 -07:00 committed by Mark McDowall
parent a77ef187af
commit 85e2855981
32 changed files with 980 additions and 46 deletions

View File

@ -11,7 +11,7 @@ import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch'; import Switch from 'Components/Router/Switch';
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
import SeriesIndex from 'Series/Index/SeriesIndex'; import SeriesIndex from 'Series/Index/SeriesIndex';
import CustomFormatSettingsConnector from 'Settings/CustomFormats/CustomFormatSettingsConnector'; import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector'; import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector'; import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
@ -179,7 +179,7 @@ function AppRoutes(props) {
<Route <Route
path="/settings/customformats" path="/settings/customformats"
component={CustomFormatSettingsConnector} component={CustomFormatSettingsPage}
/> />
<Route <Route

View File

@ -2,6 +2,7 @@ import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
import CalendarAppState from './CalendarAppState'; import CalendarAppState from './CalendarAppState';
import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState';
import EpisodesAppState from './EpisodesAppState'; import EpisodesAppState from './EpisodesAppState';
import ParseAppState from './ParseAppState';
import QueueAppState from './QueueAppState'; import QueueAppState from './QueueAppState';
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState'; import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
import SettingsAppState from './SettingsAppState'; import SettingsAppState from './SettingsAppState';
@ -44,6 +45,7 @@ interface AppState {
episodesSelection: EpisodesAppState; episodesSelection: EpisodesAppState;
episodeFiles: EpisodeFilesAppState; episodeFiles: EpisodeFilesAppState;
interactiveImport: InteractiveImportAppState; interactiveImport: InteractiveImportAppState;
parse: ParseAppState;
seriesIndex: SeriesIndexAppState; seriesIndex: SeriesIndexAppState;
settings: SettingsAppState; settings: SettingsAppState;
series: SeriesAppState; series: SeriesAppState;

View File

@ -0,0 +1,54 @@
import ModelBase from 'App/ModelBase';
import { AppSectionItemState } from 'App/State/AppSectionState';
import Episode from 'Episode/Episode';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import Series from 'Series/Series';
import CustomFormat from 'typings/CustomFormat';
export interface SeriesTitleInfo {
title: string;
titleWithoutYear: string;
year: number;
allTitles: string[];
}
export interface ParsedEpisodeInfo {
releaseTitle: string;
seriesTitle: string;
seriesTitleInfo: SeriesTitleInfo;
quality: QualityModel;
seasonNumber: number;
episodeNumbers: number[];
absoluteEpisodeNumbers: number[];
specialAbsoluteEpisodeNumbers: number[];
languages: Language[];
fullSeason: boolean;
isPartialSeason: boolean;
isMultiSeason: boolean;
isSeasonExtra: boolean;
special: boolean;
releaseHash: string;
seasonPart: number;
releaseGroup?: string;
releaseTokens: string;
airDate?: string;
isDaily: boolean;
isAbsoluteNumbering: boolean;
isPossibleSpecialEpisode: boolean;
isPossibleSceneSeasonSpecial: boolean;
}
export interface ParseModel extends ModelBase {
title: string;
parsedEpisodeInfo: ParsedEpisodeInfo;
series?: Series;
episodes: Episode[];
languages?: Language[];
customFormats?: CustomFormat[];
customFormatScore?: number;
}
type ParseAppState = AppSectionItemState<ParseModel>;
export default ParseAppState;

View File

@ -5,8 +5,8 @@ import { isLocked } from 'Utilities/scrollLock';
import styles from './PageContentBody.css'; import styles from './PageContentBody.css';
interface PageContentBodyProps { interface PageContentBodyProps {
className: string; className?: string;
innerClassName: string; innerClassName?: string;
children: ReactNode; children: ReactNode;
initialScrollTop?: number; initialScrollTop?: number;
onScroll?: (payload: OnScroll) => void; onScroll?: (payload: OnScroll) => void;

View File

@ -167,6 +167,10 @@ const links = [
{ {
title: 'Log Files', title: 'Log Files',
to: '/system/logs/files' to: '/system/logs/files'
},
{
title: 'Parse Testing',
to: '/system/parse'
} }
] ]
} }

View File

@ -31,6 +31,7 @@ import {
faBookReader as fasBookReader, faBookReader as fasBookReader,
faBroadcastTower as fasBroadcastTower, faBroadcastTower as fasBroadcastTower,
faBug as fasBug, faBug as fasBug,
faCalculator as fasCalculator,
faCalendarAlt as fasCalendarAlt, faCalendarAlt as fasCalendarAlt,
faCaretDown as fasCaretDown, faCaretDown as fasCaretDown,
faCheck as fasCheck, faCheck as fasCheck,
@ -174,6 +175,7 @@ export const PAGE_PREVIOUS = fasBackward;
export const PAGE_NEXT = fasForward; export const PAGE_NEXT = fasForward;
export const PAGE_LAST = fasFastForward; export const PAGE_LAST = fasFastForward;
export const PARENT = fasLevelUpAlt; export const PARENT = fasLevelUpAlt;
export const PARSE = fasCalculator;
export const PAUSED = fasPause; export const PAUSED = fasPause;
export const PENDING = farClock; export const PENDING = farClock;
export const PROFILE = fasUser; export const PROFILE = fasUser;

View File

@ -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;
}

12
frontend/src/Parse/Parse.css.d.ts vendored Normal file
View File

@ -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;

View File

@ -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(fetch({ title: '' }));
}, [setTitle, dispatch]);
useEffect(
() => {
return () => {
dispatch(clear());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<PageContent title="Parse">
<PageContentBody>
<div className={styles.inputContainer}>
<div className={styles.inputIconContainer}>
<Icon name={icons.PARSE} size={20} />
</div>
<TextInput
className={styles.input}
name="title"
value={title}
placeholder="eg. Series.Title.S01E05.720p.HDTV-RlsGroup"
autoFocus={true}
onChange={onInputChange}
/>
<Button className={styles.clearButton} onPress={onClearPress}>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
Error parsing, please try again.
</div>
<div>{getErrorMessage(error)}</div>
</div>
) : null}
{!isFetching && title && !error && !item.parsedEpisodeInfo ? (
<div className={styles.message}>
Unable to parse the provided title, please try again.
</div>
) : null}
{!isFetching && !error && item.parsedEpisodeInfo ? (
<ParseResult item={item} />
) : null}
{title ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
Enter a release title in the input above
</div>
<div>
Sonarr will attempt to parse the title and show you details about
it
</div>
</div>
)}
</PageContentBody>
</PageContent>
);
}
export default Parse;

View File

@ -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 (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ParseModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ParseModal;

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,124 @@
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 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(fetch({ title: '' }));
}, [setTitle, dispatch]);
useEffect(
() => {
return () => {
dispatch(clear());
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>Test Parsing</ModalHeader>
<ModalBody>
<div className={styles.inputContainer}>
<div className={styles.inputIconContainer}>
<Icon name={icons.PARSE} size={20} />
</div>
<TextInput
className={styles.input}
name="title"
value={title}
placeholder="eg. Series.Title.S01E05.720p.HDTV-RlsGroup"
autoFocus={true}
onChange={onInputChange}
/>
<Button className={styles.clearButton} onPress={onClearPress}>
<Icon name={icons.REMOVE} size={20} />
</Button>
</div>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && !!error ? (
<div className={styles.message}>
<div className={styles.helpText}>
Error parsing, please try again.
</div>
<div>{getErrorMessage(error)}</div>
</div>
) : null}
{!isFetching && title && !error && !item.parsedEpisodeInfo ? (
<div className={styles.message}>
Unable to parse the provided title, please try again.
</div>
) : null}
{!isFetching && !error && item.parsedEpisodeInfo ? (
<ParseResult item={item} />
) : null}
{title ? null : (
<div className={styles.message}>
<div className={styles.helpText}>
Enter a release title in the input above
</div>
<div>
Sonarr will attempt to parse the title and show you details about
it
</div>
</div>
)}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Close</Button>
</ModalFooter>
</ModalContent>
);
}
export default ParseModalContent;

View File

@ -0,0 +1,20 @@
.item {
display: flex;
}
.title {
margin-right: 20px;
width: 250px;
text-align: right;
font-weight: bold;
}
.description {
/* composes: description from '~Components/DescriptionList/DescriptionListItemTitle.css'; */
}
@media (max-width: $breakpointSmall) {
.item {
display: block;
}
}

View File

@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'description': string;
'item': string;
'title': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,234 @@
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';
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 (
<div>
<FieldSet legend={translate('Release')}>
<ParseResultItem
title={translate('Release Title')}
data={releaseTitle}
/>
<ParseResultItem title={translate('Series Title')} data={seriesTitle} />
<ParseResultItem
title={translate('Year')}
data={seriesTitleInfo.year > 0 ? seriesTitleInfo.year : '-'}
/>
<ParseResultItem
title={translate('All Titles')}
data={
seriesTitleInfo.allTitles?.length > 0
? seriesTitleInfo.allTitles.join(', ')
: '-'
}
/>
<ParseResultItem
title={translate('Release Group')}
data={releaseGroup ?? '-'}
/>
<ParseResultItem
title={translate('Release Hash')}
data={releaseHash ? releaseHash : '-'}
/>
</FieldSet>
{/*
Year
Secondary titles
special episode
*/}
<FieldSet legend={translate('Episode Info')}>
<ParseResultItem
title={translate('Season Number')}
data={
seasonNumber === 0 && absoluteEpisodeNumbers.length
? '-'
: seasonNumber
}
/>
<ParseResultItem
title={translate('Episode Number(s)')}
data={episodeNumbers.join(', ') || '-'}
/>
<ParseResultItem
title={translate('Absolute Episode Number(s)')}
data={
absoluteEpisodeNumbers.length
? absoluteEpisodeNumbers.join(', ')
: '-'
}
/>
<ParseResultItem
title={translate('Special')}
data={special ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('Full Season')}
data={fullSeason ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('Multi-Season')}
data={isMultiSeason ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('Partial Season')}
data={isPartialSeason ? 'True' : 'False'}
/>
<ParseResultItem
title={translate('Daily')}
data={isDaily ? 'True' : 'False'}
/>
<ParseResultItem title={translate('Air Date')} data={airDate ?? '-'} />
</FieldSet>
<FieldSet legend={translate('Quality')}>
<ParseResultItem
title={translate('Quality')}
data={quality.quality.name}
/>
<ParseResultItem
title={translate('Version')}
data={quality.revision.version > 1 ? quality.revision.version : '-'}
/>
<ParseResultItem
title={translate('Real')}
data={quality.revision.real ? 'True' : '-'}
/>
<ParseResultItem
title={translate('Proper')}
data={
quality.revision.version > 1 && !quality.revision.isRepack
? 'True'
: '-'
}
/>
<ParseResultItem
title={translate('Repack')}
data={quality.revision.isRepack ? 'True' : '-'}
/>
</FieldSet>
<FieldSet legend={translate('Languages')}>
<ParseResultItem
title={translate('Languages')}
data={finalLanguages.map((l) => l.name).join(', ')}
/>
</FieldSet>
<FieldSet legend={translate('Details')}>
<ParseResultItem
title={translate('Matched to Series')}
data={
series ? (
<SeriesTitleLink
titleSlug={series.titleSlug}
title={series.title}
/>
) : (
'-'
)
}
/>
<ParseResultItem
title={translate('Matched to Season')}
data={episodes.length ? episodes[0].seasonNumber : '-'}
/>
<ParseResultItem
title={translate('Matched to Episodes')}
data={
episodes.length ? (
<div>
{episodes.map((e) => {
return (
<div key={e.id}>
{e.episodeNumber}
{series?.seriesType === 'anime' && e.absoluteEpisodeNumber
? ` (${e.absoluteEpisodeNumber})`
: ''}{' '}
{` - ${e.title}`}
</div>
);
})}
</div>
) : (
'-'
)
}
/>
<ParseResultItem
title={translate('Custom Formats')}
data={<EpisodeFormats formats={customFormats} />}
/>
<ParseResultItem
title={translate('Custom Format Score')}
data={customFormatScore}
/>
</FieldSet>
</div>
);
}
export default ParseResult;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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 (
<div className={styles.item}>
<div className={styles.title}>{title}</div>
<div>{data}</div>
</div>
);
}
export default ParseResultItem;

View File

@ -0,0 +1,30 @@
import React, { Fragment, useCallback, useState } from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import { icons } from 'Helpers/Props';
import ParseModal from 'Parse/ParseModal';
function ParseToolbarButton() {
const [isParseModalOpen, setIsParseModalOpen] = useState(false);
const onOpenParseModalPress = useCallback(() => {
setIsParseModalOpen(true);
}, [setIsParseModalOpen]);
const onParseModalClose = useCallback(() => {
setIsParseModalOpen(false);
}, [setIsParseModalOpen]);
return (
<Fragment>
<PageToolbarButton
label="Test Parsing"
iconName={icons.PARSE}
onPress={onOpenParseModalPress}
/>
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
</Fragment>
);
}
export default ParseToolbarButton;

View File

@ -0,0 +1,12 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import ParseAppState from 'App/State/ParseAppState';
export default function parseStateSelector() {
return createSelector(
(state: AppState) => state.parse,
(parse: ParseAppState) => {
return parse;
}
);
}

View File

@ -23,6 +23,7 @@ import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptions
import withScrollPosition from 'Components/withScrollPosition'; import withScrollPosition from 'Components/withScrollPosition';
import { align, icons, kinds } from 'Helpers/Props'; import { align, icons, kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection'; import SortDirection from 'Helpers/Props/SortDirection';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import NoSeries from 'Series/NoSeries'; import NoSeries from 'Series/NoSeries';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { fetchQueueDetails } from 'Store/Actions/queueActions'; import { fetchQueueDetails } from 'Store/Actions/queueActions';
@ -246,6 +247,9 @@ const SeriesIndex = withScrollPosition((props: SeriesIndexProps) => {
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
overflowComponent={SeriesIndexSelectAllMenuItem} overflowComponent={SeriesIndexSelectAllMenuItem}
/> />
<PageToolbarSeparator />
<ParseToolbarButton />
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection <PageToolbarSection

View File

@ -1,32 +0,0 @@
import React, { Component } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
class CustomFormatSettingsConnector extends Component {
//
// Render
render() {
return (
<PageContent title="Custom Format Settings">
<SettingsToolbarConnector
showSave={false}
/>
<PageContentBody>
<DndProvider backend={HTML5Backend}>
<CustomFormatsConnector />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
}
export default CustomFormatSettingsConnector;

View File

@ -0,0 +1,41 @@
import React, { Fragment } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
function CustomFormatSettingsPage() {
return (
<PageContent title="Custom Format Settings">
<SettingsToolbarConnector
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
showSave={false}
additionalButtons={
<Fragment>
<PageToolbarSeparator />
<ParseToolbarButton />
</Fragment>
}
/>
<PageContentBody>
{/* TODO: Upgrade react-dnd to get typings, we're 2 major versions behind */}
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<DndProvider backend={HTML5Backend}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<CustomFormatsConnector />
</DndProvider>
</PageContentBody>
</PageContent>
);
}
export default CustomFormatSettingsPage;

View File

@ -134,6 +134,7 @@ const historyShape = {
}; };
SettingsToolbarConnector.propTypes = { SettingsToolbarConnector.propTypes = {
showSave: PropTypes.bool,
hasPendingChanges: PropTypes.bool.isRequired, hasPendingChanges: PropTypes.bool.isRequired,
history: PropTypes.shape(historyShape).isRequired, history: PropTypes.shape(historyShape).isRequired,
onSavePress: PropTypes.func, onSavePress: PropTypes.func,

View File

@ -14,6 +14,7 @@ import * as importSeries from './importSeriesActions';
import * as interactiveImportActions from './interactiveImportActions'; import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions'; import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions'; import * as organizePreview from './organizePreviewActions';
import * as parse from './parseActions';
import * as paths from './pathActions'; import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions'; import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions'; import * as queue from './queueActions';
@ -44,6 +45,7 @@ export default [
interactiveImportActions, interactiveImportActions,
oAuth, oAuth,
organizePreview, organizePreview,
parse,
paths, paths,
providerOptions, providerOptions,
queue, queue,

View File

@ -0,0 +1,109 @@
import { Dispatch } from 'redux';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import AppState from 'App/State/AppState';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, update } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
import createClearReducer from './Creators/Reducers/createClearReducer';
interface FetchPayload {
title: string;
}
//
// Variables
export const section = 'parse';
let parseTimeout: number | null = null;
let abortCurrentRequest: (() => void) | null = null;
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
item: {},
};
//
// Actions Types
export const FETCH = 'parse/fetch';
export const CLEAR = 'parse/clear';
//
// Action Creators
export const fetch = createThunk(FETCH);
export const clear = createAction(CLEAR);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH]: function (
_getState: () => AppState,
payload: FetchPayload,
dispatch: Dispatch
) {
if (parseTimeout) {
clearTimeout(parseTimeout);
}
parseTimeout = window.setTimeout(async () => {
if (abortCurrentRequest) {
abortCurrentRequest();
}
const { request, abortRequest } = createAjaxRequest({
url: '/parse',
data: {
title: payload.title,
},
});
try {
const data = await request;
dispatch(
batchActions([
update({ section, data }),
set({
section,
isFetching: false,
isPopulated: true,
error: null,
}),
])
);
} catch (error) {
dispatch(
set({
section,
isAdding: false,
isAdded: false,
addError: error,
})
);
}
abortCurrentRequest = abortRequest;
}, 300);
},
});
//
// Reducers
export const reducers = createHandleActions(
{
[CLEAR]: createClearReducer(section, defaultState),
},
defaultState,
section
);

View File

@ -4,23 +4,25 @@ import AppState from 'App/State/AppState';
type GetState = () => AppState; type GetState = () => AppState;
type Thunk = ( type Thunk = (
getState: GetState, getState: GetState,
identity: unknown, identityFn: never,
dispatch: Dispatch dispatch: Dispatch
) => unknown; ) => unknown;
const thunks: Record<string, Thunk> = {}; const thunks: Record<string, Thunk> = {};
function identity(payload: unknown) { function identity<T, TResult>(payload: T): TResult {
return payload; return payload as unknown as TResult;
} }
export function createThunk(type: string, identityFunction = identity) { export function createThunk(type: string, identityFunction = identity) {
return function (payload: unknown = {}) { return function <T>(payload?: T) {
return function (dispatch: Dispatch, getState: GetState) { return function (dispatch: Dispatch, getState: GetState) {
const thunk = thunks[type]; const thunk = thunks[type];
if (thunk) { if (thunk) {
return thunk(getState, identityFunction(payload), dispatch); const finalPayload = payload ?? {};
return thunk(getState, identityFunction(finalPayload), dispatch);
} }
throw Error(`Thunk handler has not been registered for ${type}`); throw Error(`Thunk handler has not been registered for ${type}`);

View File

@ -101,6 +101,7 @@
"@types/react-router-dom": "5.3.3", "@types/react-router-dom": "5.3.3",
"@types/react-text-truncate": "0.14.1", "@types/react-text-truncate": "0.14.1",
"@types/react-window": "1.8.5", "@types/react-window": "1.8.5",
"@types/redux-actions": "2.6.2",
"@types/webpack-livereload-plugin": "^2.3.3", "@types/webpack-livereload-plugin": "^2.3.3",
"@typescript-eslint/eslint-plugin": "5.59.5", "@typescript-eslint/eslint-plugin": "5.59.5",
"@typescript-eslint/parser": "5.59.5", "@typescript-eslint/parser": "5.59.5",

View File

@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Download.Aggregation; using NzbDrone.Core.Download.Aggregation;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using Sonarr.Api.V3.CustomFormats;
using Sonarr.Api.V3.Episodes; using Sonarr.Api.V3.Episodes;
using Sonarr.Api.V3.Series; using Sonarr.Api.V3.Series;
using Sonarr.Http; using Sonarr.Http;
@ -13,12 +15,15 @@ namespace Sonarr.Api.V3.Parse
{ {
private readonly IParsingService _parsingService; private readonly IParsingService _parsingService;
private readonly IRemoteEpisodeAggregationService _aggregationService; private readonly IRemoteEpisodeAggregationService _aggregationService;
private readonly ICustomFormatCalculationService _formatCalculator;
public ParseController(IParsingService parsingService, public ParseController(IParsingService parsingService,
IRemoteEpisodeAggregationService aggregationService) IRemoteEpisodeAggregationService aggregationService,
ICustomFormatCalculationService formatCalculator)
{ {
_parsingService = parsingService; _parsingService = parsingService;
_aggregationService = aggregationService; _aggregationService = aggregationService;
_formatCalculator = formatCalculator;
} }
[HttpGet] [HttpGet]
@ -42,16 +47,22 @@ namespace Sonarr.Api.V3.Parse
var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0); var remoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0);
_aggregationService.Augment(remoteEpisode);
if (remoteEpisode != null) if (remoteEpisode != null)
{ {
_aggregationService.Augment(remoteEpisode);
remoteEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(remoteEpisode, 0);
remoteEpisode.CustomFormatScore = remoteEpisode?.Series?.QualityProfile?.Value.CalculateCustomFormatScore(remoteEpisode.CustomFormats) ?? 0;
return new ParseResource return new ParseResource
{ {
Title = title, Title = title,
ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo, ParsedEpisodeInfo = remoteEpisode.ParsedEpisodeInfo,
Series = remoteEpisode.Series.ToResource(), Series = remoteEpisode.Series.ToResource(),
Episodes = remoteEpisode.Episodes.ToResource() Episodes = remoteEpisode.Episodes.ToResource(),
Languages = remoteEpisode.Languages,
CustomFormats = remoteEpisode.CustomFormats?.ToResource(false),
CustomFormatScore = remoteEpisode.CustomFormatScore
}; };
} }
else else

View File

@ -1,5 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Parser.Model;
using Sonarr.Api.V3.CustomFormats;
using Sonarr.Api.V3.Episodes; using Sonarr.Api.V3.Episodes;
using Sonarr.Api.V3.Series; using Sonarr.Api.V3.Series;
using Sonarr.Http.REST; using Sonarr.Http.REST;
@ -12,5 +14,8 @@ namespace Sonarr.Api.V3.Parse
public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; } public ParsedEpisodeInfo ParsedEpisodeInfo { get; set; }
public SeriesResource Series { get; set; } public SeriesResource Series { get; set; }
public List<EpisodeResource> Episodes { get; set; } public List<EpisodeResource> Episodes { get; set; }
public List<Language> Languages { get; set; }
public List<CustomFormatResource> CustomFormats { get; set; }
public int CustomFormatScore { get; set; }
} }
} }

View File

@ -1478,6 +1478,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
"@types/redux-actions@2.6.2":
version "2.6.2"
resolved "https://registry.yarnpkg.com/@types/redux-actions/-/redux-actions-2.6.2.tgz#5956d9e7b9a644358e2c0610f47b1fa3060edc21"
integrity sha512-TvcINy8rWFANcpc3EiEQX9Yv3owM3d3KIrqr2ryUIOhYIYzXA/bhDZeGSSSuai62iVR2qMZUgz9tQ5kr0Kl+Tg==
"@types/scheduler@*": "@types/scheduler@*":
version "0.16.3" version "0.16.3"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"