New: Added UI for parsing release names

Closes #5263
pull/5789/head
Mark McDowall 1 year ago committed by Mark McDowall
parent a77ef187af
commit 85e2855981

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -4,23 +4,25 @@ import AppState from 'App/State/AppState';
type GetState = () => AppState;
type Thunk = (
getState: GetState,
identity: unknown,
identityFn: never,
dispatch: Dispatch
) => unknown;
const thunks: Record<string, Thunk> = {};
function identity(payload: unknown) {
return payload;
function identity<T, TResult>(payload: T): TResult {
return payload as unknown as TResult;
}
export function createThunk(type: string, identityFunction = identity) {
return function (payload: unknown = {}) {
return function <T>(payload?: T) {
return function (dispatch: Dispatch, getState: GetState) {
const thunk = thunks[type];
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}`);

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

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

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

@ -1478,6 +1478,11 @@
dependencies:
"@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@*":
version "0.16.3"
resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"

Loading…
Cancel
Save