diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 123212461..c2c95f96d 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -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) { ; + +export default ParseAppState; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx index a36010749..ce9b0e7e4 100644 --- a/frontend/src/Components/Page/PageContentBody.tsx +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -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; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index c7c6844a8..372c69b81 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -167,6 +167,10 @@ const links = [ { title: 'Log Files', to: '/system/logs/files' + }, + { + title: 'Parse Testing', + to: '/system/parse' } ] } diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index b4921d3b0..37e670731 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -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; 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..33097e0be --- /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(fetch({ title: '' })); + }, [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..c6fb54b91 --- /dev/null +++ b/frontend/src/Parse/ParseModalContent.tsx @@ -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 ( + + Test Parsing + + + + + + + + + + + + + + + {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 + + + )} + + + + Close + + + ); +} + +export default ParseModalContent; diff --git a/frontend/src/Parse/ParseResult.css b/frontend/src/Parse/ParseResult.css new file mode 100644 index 000000000..d5de120fa --- /dev/null +++ b/frontend/src/Parse/ParseResult.css @@ -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; + } +} diff --git a/frontend/src/Parse/ParseResult.css.d.ts b/frontend/src/Parse/ParseResult.css.d.ts new file mode 100644 index 000000000..13942714e --- /dev/null +++ b/frontend/src/Parse/ParseResult.css.d.ts @@ -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; diff --git a/frontend/src/Parse/ParseResult.tsx b/frontend/src/Parse/ParseResult.tsx new file mode 100644 index 000000000..575b8467f --- /dev/null +++ b/frontend/src/Parse/ParseResult.tsx @@ -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 ( + + + + + + + 0 ? seriesTitleInfo.year : '-'} + /> + + 0 + ? seriesTitleInfo.allTitles.join(', ') + : '-' + } + /> + + + + + + + {/* + + Year + Secondary titles + special episode + + */} + + + + + + + + + + + + + + + + + + + + + + + + + 1 ? quality.revision.version : '-'} + /> + + + + 1 && !quality.revision.isRepack + ? 'True' + : '-' + } + /> + + + + + + l.name).join(', ')} + /> + + + + + ) : ( + '-' + ) + } + /> + + + + + {episodes.map((e) => { + return ( + + {e.episodeNumber} + {series?.seriesType === 'anime' && e.absoluteEpisodeNumber + ? ` (${e.absoluteEpisodeNumber})` + : ''}{' '} + {` - ${e.title}`} + + ); + })} + + ) : ( + '-' + ) + } + /> + + } + /> + + + + + ); +} + +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 ( + + {title} + {data} + + ); +} + +export default ParseResultItem; diff --git a/frontend/src/Parse/ParseToolbarButton.tsx b/frontend/src/Parse/ParseToolbarButton.tsx new file mode 100644 index 000000000..66724d852 --- /dev/null +++ b/frontend/src/Parse/ParseToolbarButton.tsx @@ -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 ( + + + + + + ); +} + +export default ParseToolbarButton; diff --git a/frontend/src/Parse/parseStateSelector.ts b/frontend/src/Parse/parseStateSelector.ts new file mode 100644 index 000000000..7abcfeca1 --- /dev/null +++ b/frontend/src/Parse/parseStateSelector.ts @@ -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; + } + ); +} diff --git a/frontend/src/Series/Index/SeriesIndex.tsx b/frontend/src/Series/Index/SeriesIndex.tsx index 9c54ba97e..58ec6f17a 100644 --- a/frontend/src/Series/Index/SeriesIndex.tsx +++ b/frontend/src/Series/Index/SeriesIndex.tsx @@ -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} /> + + + - - - - - - - - - ); - } -} - -export default CustomFormatSettingsConnector; - diff --git a/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx new file mode 100644 index 000000000..cb86066b1 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormatSettingsPage.tsx @@ -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 ( + + + + + + + } + /> + + + {/* TODO: Upgrade react-dnd to get typings, we're 2 major versions behind */} + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */} + {/* @ts-ignore */} + + + + + ); +} + +export default CustomFormatSettingsPage; diff --git a/frontend/src/Settings/SettingsToolbarConnector.js b/frontend/src/Settings/SettingsToolbarConnector.js index 1e6f7a589..65d937ab8 100644 --- a/frontend/src/Settings/SettingsToolbarConnector.js +++ b/frontend/src/Settings/SettingsToolbarConnector.js @@ -134,6 +134,7 @@ const historyShape = { }; SettingsToolbarConnector.propTypes = { + showSave: PropTypes.bool, hasPendingChanges: PropTypes.bool.isRequired, history: PropTypes.shape(historyShape).isRequired, onSavePress: PropTypes.func, diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index c92a42d88..26eb89d1f 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -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, diff --git a/frontend/src/Store/Actions/parseActions.ts b/frontend/src/Store/Actions/parseActions.ts new file mode 100644 index 000000000..54f72ef99 --- /dev/null +++ b/frontend/src/Store/Actions/parseActions.ts @@ -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 +); diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts index 7244e920e..fd277211e 100644 --- a/frontend/src/Store/thunks.ts +++ b/frontend/src/Store/thunks.ts @@ -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 = {}; -function identity(payload: unknown) { - return payload; +function identity(payload: T): TResult { + return payload as unknown as TResult; } export function createThunk(type: string, identityFunction = identity) { - return function (payload: unknown = {}) { + return function (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}`); diff --git a/package.json b/package.json index 0704310bf..9a88c0f42 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Sonarr.Api.V3/Parse/ParseController.cs b/src/Sonarr.Api.V3/Parse/ParseController.cs index 84060d90f..2f0719741 100644 --- a/src/Sonarr.Api.V3/Parse/ParseController.cs +++ b/src/Sonarr.Api.V3/Parse/ParseController.cs @@ -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 diff --git a/src/Sonarr.Api.V3/Parse/ParseResource.cs b/src/Sonarr.Api.V3/Parse/ParseResource.cs index 131b75442..76d1edf2d 100644 --- a/src/Sonarr.Api.V3/Parse/ParseResource.cs +++ b/src/Sonarr.Api.V3/Parse/ParseResource.cs @@ -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 Episodes { get; set; } + public List Languages { get; set; } + public List CustomFormats { get; set; } + public int CustomFormatScore { get; set; } } } diff --git a/yarn.lock b/yarn.lock index dcd293006..d4bae3f59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"