diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index f33fcc6920..82dcc9341b 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -3,13 +3,16 @@ import CalendarAppState from './CalendarAppState'; import CommandAppState from './CommandAppState'; import HistoryAppState from './HistoryAppState'; import InteractiveImportAppState from './InteractiveImportAppState'; +import MovieBlocklistAppState from './MovieBlocklistAppState'; import MovieCollectionAppState from './MovieCollectionAppState'; import MovieCreditAppState from './MovieCreditAppState'; import MovieFilesAppState from './MovieFilesAppState'; +import MovieHistoryAppState from './MovieHistoryAppState'; import MoviesAppState, { MovieIndexAppState } from './MoviesAppState'; import ParseAppState from './ParseAppState'; import PathsAppState from './PathsAppState'; import QueueAppState from './QueueAppState'; +import ReleasesAppState from './ReleasesAppState'; import RootFolderAppState from './RootFolderAppState'; import SettingsAppState from './SettingsAppState'; import SystemAppState from './SystemAppState'; @@ -66,14 +69,17 @@ interface AppState { commands: CommandAppState; history: HistoryAppState; interactiveImport: InteractiveImportAppState; + movieBlocklist: MovieBlocklistAppState; movieCollections: MovieCollectionAppState; movieCredits: MovieCreditAppState; movieFiles: MovieFilesAppState; + movieHistory: MovieHistoryAppState; movieIndex: MovieIndexAppState; movies: MoviesAppState; parse: ParseAppState; paths: PathsAppState; queue: QueueAppState; + releases: ReleasesAppState; rootFolders: RootFolderAppState; settings: SettingsAppState; system: SystemAppState; diff --git a/frontend/src/App/State/MovieBlocklistAppState.ts b/frontend/src/App/State/MovieBlocklistAppState.ts new file mode 100644 index 0000000000..1b3d25ab03 --- /dev/null +++ b/frontend/src/App/State/MovieBlocklistAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Blocklist from 'typings/Blocklist'; + +type MovieBlocklistAppState = AppSectionState; + +export default MovieBlocklistAppState; diff --git a/frontend/src/App/State/MovieHistoryAppState.ts b/frontend/src/App/State/MovieHistoryAppState.ts new file mode 100644 index 0000000000..44a024cdf4 --- /dev/null +++ b/frontend/src/App/State/MovieHistoryAppState.ts @@ -0,0 +1,6 @@ +import AppSectionState from 'App/State/AppSectionState'; +import History from 'typings/History'; + +type MovieHistoryAppState = AppSectionState; + +export default MovieHistoryAppState; diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts new file mode 100644 index 0000000000..350f6eac8e --- /dev/null +++ b/frontend/src/App/State/ReleasesAppState.ts @@ -0,0 +1,10 @@ +import AppSectionState, { + AppSectionFilterState, +} from 'App/State/AppSectionState'; +import Release from 'typings/Release'; + +interface ReleasesAppState + extends AppSectionState, + AppSectionFilterState {} + +export default ReleasesAppState; diff --git a/frontend/src/Components/Table/Column.ts b/frontend/src/Components/Table/Column.ts index 24674c3fc4..22d22e9636 100644 --- a/frontend/src/Components/Table/Column.ts +++ b/frontend/src/Components/Table/Column.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { SortDirection } from 'Helpers/Props/sortDirections'; type PropertyFunction = () => T; @@ -9,6 +10,7 @@ interface Column { className?: string; columnLabel?: string; isSortable?: boolean; + fixedSortDirection?: SortDirection; isVisible: boolean; isModifiable?: boolean; } diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.js b/frontend/src/InteractiveSearch/InteractiveSearch.js deleted file mode 100644 index 8c3f2c4107..0000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearch.js +++ /dev/null @@ -1,240 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Fragment } from 'react'; -import Alert from 'Components/Alert'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageMenuButton from 'Components/Menu/PageMenuButton'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { align, icons, kinds, sortDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; -import InteractiveSearchRowConnector from './InteractiveSearchRowConnector'; -import styles from './InteractiveSearch.css'; - -const columns = [ - { - name: 'protocol', - label: () => translate('Source'), - isSortable: true, - isVisible: true - }, - { - name: 'age', - label: () => translate('Age'), - isSortable: true, - isVisible: true - }, - { - name: 'title', - label: () => translate('Title'), - isSortable: true, - isVisible: true - }, - { - name: 'indexer', - label: () => translate('Indexer'), - isSortable: true, - isVisible: true - }, - { - name: 'history', - label: () => translate('History'), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - }, - { - name: 'size', - label: () => translate('Size'), - isSortable: true, - isVisible: true - }, - { - name: 'peers', - label: () => translate('Peers'), - isSortable: true, - isVisible: true - }, - { - name: 'languages', - label: () => translate('Language'), - isSortable: true, - isVisible: true - }, - { - name: 'qualityWeight', - label: () => translate('Quality'), - isSortable: true, - isVisible: true - }, - { - name: 'customFormatScore', - label: React.createElement(Icon, { - name: icons.SCORE, - title: () => translate('CustomFormatScore') - }), - isSortable: true, - isVisible: true - }, - { - name: 'indexerFlags', - label: React.createElement(Icon, { - name: icons.FLAG, - title: () => translate('IndexerFlags') - }), - isSortable: true, - isVisible: true - }, - { - name: 'rejections', - label: React.createElement(Icon, { - name: icons.DANGER, - title: () => translate('Rejections') - }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - }, - { - name: 'releaseWeight', - label: React.createElement(Icon, { name: icons.DOWNLOAD }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - } -]; - -function InteractiveSearch(props) { - const { - searchPayload, - isFetching, - isPopulated, - error, - totalReleasesCount, - items, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - longDateFormat, - timeFormat, - onSortPress, - onFilterSelect, - onGrabPress - } = props; - - const errorMessage = getErrorMessage(error); - const type = 'movies'; - - return ( -
-
- -
- - { - isFetching ? : null - } - - { - !isFetching && error ? - - { - errorMessage ? - - {translate('InteractiveSearchResultsFailedErrorMessage', { message: errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1) })} - : - translate('MovieSearchResultsLoadError') - } - : - null - } - - { - !isFetching && isPopulated && !totalReleasesCount ? - - {translate('NoResultsFound')} - : - null - } - - { - !!totalReleasesCount && isPopulated && !items.length ? - - {translate('AllResultsHiddenFilter')} - : - null - } - - { - isPopulated && !!items.length ? - - - { - items.map((item) => { - return ( - - ); - }) - } - -
: - null - } - - { - totalReleasesCount !== items.length && !!items.length ? - - {translate('SomeResultsHiddenFilter')} - : - null - } -
- ); -} - -InteractiveSearch.propTypes = { - searchPayload: PropTypes.object.isRequired, - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalReleasesCount: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onSortPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired -}; - -export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearch.tsx b/frontend/src/InteractiveSearch/InteractiveSearch.tsx new file mode 100644 index 0000000000..068dc83c07 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearch.tsx @@ -0,0 +1,263 @@ +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState'; +import ReleasesAppState from 'App/State/ReleasesAppState'; +import Alert from 'Components/Alert'; +import Icon from 'Components/Icon'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import PageMenuButton from 'Components/Menu/PageMenuButton'; +import Column from 'Components/Table/Column'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import { align, icons, kinds, sortDirections } from 'Helpers/Props'; +import { SortDirection } from 'Helpers/Props/sortDirections'; +import { fetchMovieBlocklist } from 'Store/Actions/movieBlocklistActions'; +import { fetchMovieHistory } from 'Store/Actions/movieHistoryActions'; +import { + fetchReleases, + grabRelease, + setReleasesFilter, + setReleasesSort, +} from 'Store/Actions/releaseActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import InteractiveSearchFilterModal from './InteractiveSearchFilterModal'; +import InteractiveSearchPayload from './InteractiveSearchPayload'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearch.css'; + +const columns: Column[] = [ + { + name: 'protocol', + label: () => translate('Source'), + isSortable: true, + isVisible: true, + }, + { + name: 'age', + label: () => translate('Age'), + isSortable: true, + isVisible: true, + }, + { + name: 'title', + label: () => translate('Title'), + isSortable: true, + isVisible: true, + }, + { + name: 'indexer', + label: () => translate('Indexer'), + isSortable: true, + isVisible: true, + }, + { + name: 'history', + label: () => translate('History'), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true, + }, + { + name: 'size', + label: () => translate('Size'), + isSortable: true, + isVisible: true, + }, + { + name: 'peers', + label: () => translate('Peers'), + isSortable: true, + isVisible: true, + }, + { + name: 'languages', + label: () => translate('Language'), + isSortable: true, + isVisible: true, + }, + { + name: 'qualityWeight', + label: () => translate('Quality'), + isSortable: true, + isVisible: true, + }, + { + name: 'customFormatScore', + label: React.createElement(Icon, { + name: icons.SCORE, + title: () => translate('CustomFormatScore'), + }), + isSortable: true, + isVisible: true, + }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { + name: icons.FLAG, + title: () => translate('IndexerFlags'), + }), + isSortable: true, + isVisible: true, + }, + { + name: 'rejections', + label: React.createElement(Icon, { + name: icons.DANGER, + title: () => translate('Rejections'), + }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true, + }, + { + name: 'releaseWeight', + label: React.createElement(Icon, { name: icons.DOWNLOAD }), + isSortable: true, + fixedSortDirection: sortDirections.ASCENDING, + isVisible: true, + }, +]; + +interface InteractiveSearchProps { + searchPayload: InteractiveSearchPayload; +} + +function InteractiveSearch({ searchPayload }: InteractiveSearchProps) { + const { + isFetching, + isPopulated, + error, + items, + totalItems, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + }: ReleasesAppState & ClientSideCollectionAppState = useSelector( + createClientSideCollectionSelector('releases') + ); + + const dispatch = useDispatch(); + + const handleFilterSelect = useCallback( + (selectedFilterKey: string | number) => { + dispatch(setReleasesFilter({ selectedFilterKey })); + }, + [dispatch] + ); + + const handleSortPress = useCallback( + (sortKey: string, sortDirection?: SortDirection) => { + dispatch(setReleasesSort({ sortKey, sortDirection })); + }, + [dispatch] + ); + + const handleGrabPress = useCallback( + (payload: object) => { + dispatch(grabRelease(payload)); + }, + [dispatch] + ); + + useEffect( + () => { + // Only fetch releases if they are not already being fetched and not yet populated. + + if (!isFetching && !isPopulated) { + dispatch(fetchReleases(searchPayload)); + + const { movieId } = searchPayload; + + if (movieId) { + dispatch(fetchMovieBlocklist({ movieId })); + dispatch(fetchMovieHistory({ movieId })); + } + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + const errorMessage = getErrorMessage(error); + + return ( +
+
+ +
+ + {isFetching ? : null} + + {!isFetching && error ? ( + + {errorMessage ? ( + <> + {translate('InteractiveSearchResultsFailedErrorMessage', { + message: + errorMessage.charAt(0).toLowerCase() + errorMessage.slice(1), + })} + + ) : ( + translate('MovieSearchResultsLoadError') + )} + + ) : null} + + {!isFetching && isPopulated && !totalItems ? ( + + {translate('NoResultsFound')} + + ) : null} + + {!!totalItems && isPopulated && !items.length ? ( + + {translate('AllResultsHiddenFilter')} + + ) : null} + + {isPopulated && !!items.length ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} + + {totalItems !== items.length && !!items.length ? ( + + {translate('SomeResultsHiddenFilter')} + + ) : null} +
+ ); +} + +export default InteractiveSearch; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchConnector.js deleted file mode 100644 index 946324647d..0000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchConnector.js +++ /dev/null @@ -1,109 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { clearMovieHistory, fetchMovieHistory } from 'Store/Actions/movieHistoryActions'; -import * as releaseActions from 'Store/Actions/releaseActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import InteractiveSearch from './InteractiveSearch'; - -function createMapStateToProps(appState) { - return createSelector( - (state) => state.releases.items.length, - createClientSideCollectionSelector('releases'), - createUISettingsSelector(), - (totalReleasesCount, releases, uiSettings) => { - return { - totalReleasesCount, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - ...releases - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchFetchReleases(payload) { - dispatch(releaseActions.fetchReleases(payload)); - }, - - dispatchFetchMovieHistory({ movieId }) { - dispatch(fetchMovieHistory({ movieId })); - }, - - dispatchClearMovieHistory() { - dispatch(clearMovieHistory()); - }, - - onSortPress(sortKey, sortDirection) { - dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(releaseActions.setReleasesFilter({ selectedFilterKey })); - }, - - onGrabPress(payload) { - dispatch(releaseActions.grabRelease(payload)); - } - }; -} - -class InteractiveSearchConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - searchPayload, - isPopulated, - dispatchFetchReleases, - dispatchFetchMovieHistory - } = this.props; - - // If search results are not yet isPopulated fetch them, - // otherwise re-show the existing props. - if (!isPopulated) { - dispatchFetchReleases(searchPayload); - } - - dispatchFetchMovieHistory(searchPayload); - } - - componentWillUnmount() { - this.props.dispatchClearMovieHistory(); - } - - // - // Render - - render() { - const { - dispatchFetchReleases, - dispatchFetchMovieHistory, - dispatchClearMovieHistory, - ...otherProps - } = this.props; - - return ( - - - ); - } -} - -InteractiveSearchConnector.propTypes = { - searchPayload: PropTypes.object.isRequired, - isPopulated: PropTypes.bool.isRequired, - dispatchFetchReleases: PropTypes.func.isRequired, - dispatchFetchMovieHistory: PropTypes.func.isRequired, - dispatchClearMovieHistory: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModal.tsx b/frontend/src/InteractiveSearch/InteractiveSearchFilterModal.tsx new file mode 100644 index 0000000000..358367074d --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModal.tsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setReleasesFilter } from 'Store/Actions/releaseActions'; + +function createReleasesSelector() { + return createSelector( + (state: AppState) => state.releases.items, + (releases) => { + return releases; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.releases.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface InteractiveSearchFilterModalProps { + isOpen: boolean; +} + +export default function InteractiveSearchFilterModal({ + ...otherProps +}: InteractiveSearchFilterModalProps) { + const sectionItems = useSelector(createReleasesSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setReleasesFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js deleted file mode 100644 index c42fb30d15..0000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setReleasesFilter } from 'Store/Actions/releaseActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.releases.items, - (state) => state.releases.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'releases' - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - dispatchSetFilter(payload) { - const action = setReleasesFilter; - dispatch(action(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts b/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts new file mode 100644 index 0000000000..493f1bc305 --- /dev/null +++ b/frontend/src/InteractiveSearch/InteractiveSearchPayload.ts @@ -0,0 +1,7 @@ +interface MovieSearchPayload { + movieId: number; +} + +type InteractiveSearchPayload = MovieSearchPayload; + +export default InteractiveSearchPayload; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx index c2bc0afb0a..c848b6e32f 100644 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.tsx @@ -1,5 +1,8 @@ import React, { useCallback, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; +import AppState from 'App/State/AppState'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; @@ -8,21 +11,18 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRow from 'Components/Table/TableRow'; import Popover from 'Components/Tooltip/Popover'; import Tooltip from 'Components/Tooltip/Tooltip'; -import type DownloadProtocol from 'DownloadClient/DownloadProtocol'; import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import Language from 'Language/Language'; import MovieFormats from 'Movie/MovieFormats'; import MovieLanguages from 'Movie/MovieLanguages'; import MovieQuality from 'Movie/MovieQuality'; -import { QualityModel } from 'Quality/Quality'; -import CustomFormat from 'typings/CustomFormat'; -import MovieBlocklist from 'typings/MovieBlocklist'; -import MovieHistory from 'typings/MovieHistory'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import Release from 'typings/Release'; import formatDateTime from 'Utilities/Date/formatDateTime'; import formatAge from 'Utilities/Number/formatAge'; import formatBytes from 'Utilities/Number/formatBytes'; import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore'; import translate from 'Utilities/String/translate'; +import InteractiveSearchPayload from './InteractiveSearchPayload'; import OverrideMatchModal from './OverrideMatch/OverrideMatchModal'; import Peers from './Peers'; import styles from './InteractiveSearchRow.css'; @@ -71,37 +71,42 @@ function getDownloadTooltip( return translate('AddToDownloadQueue'); } -interface InteractiveSearchRowProps { - guid: string; - protocol: DownloadProtocol; - age: number; - ageHours: number; - ageMinutes: number; - publishDate: string; - title: string; - infoUrl: string; - indexerId: number; - indexer: string; - size: number; - seeders?: number; - leechers?: number; - quality: QualityModel; - languages: Language[]; - customFormats: CustomFormat[]; - customFormatScore: number; - mappedMovieId?: number; - indexerFlags: string[]; - rejections: string[]; - downloadAllowed: boolean; - isGrabbing: boolean; - isGrabbed: boolean; - grabError?: string; - historyFailedData?: MovieHistory; - historyGrabbedData?: MovieHistory; - blocklistData?: MovieBlocklist; - longDateFormat: string; - timeFormat: string; - searchPayload: object; +function releaseHistorySelector({ guid }: Release) { + return createSelector( + (state: AppState) => state.movieHistory.items, + (state: AppState) => state.movieBlocklist.items, + (movieHistory, movieBlocklist) => { + let historyFailedData = null; + let blocklistedData = null; + + const historyGrabbedData = movieHistory.find( + ({ eventType, data }) => + eventType === 'grabbed' && 'guid' in data && data.guid === guid + ); + + if (historyGrabbedData) { + historyFailedData = movieHistory.find( + ({ eventType, sourceTitle }) => + eventType === 'downloadFailed' && + sourceTitle === historyGrabbedData.sourceTitle + ); + + blocklistedData = movieBlocklist.find( + (item) => item.sourceTitle === historyGrabbedData.sourceTitle + ); + } + + return { + historyGrabbedData, + historyFailedData, + blocklistedData, + }; + } + ); +} + +interface InteractiveSearchRowProps extends Release { + searchPayload: InteractiveSearchPayload; onGrabPress(...args: unknown[]): void; } @@ -130,16 +135,18 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { downloadAllowed, isGrabbing = false, isGrabbed = false, - longDateFormat, - timeFormat, grabError, - historyGrabbedData = {} as MovieHistory, - historyFailedData = {} as MovieHistory, - blocklistData = {} as MovieBlocklist, searchPayload, onGrabPress, } = props; + const { longDateFormat, timeFormat } = useSelector( + createUISettingsSelector() + ); + + const { historyGrabbedData, historyFailedData, blocklistedData } = + useSelector(releaseHistorySelector(props)); + const [isConfirmGrabModalOpen, setIsConfirmGrabModalOpen] = useState(false); const [isOverrideModalOpen, setIsOverrideModalOpen] = useState(false); @@ -211,44 +218,52 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) { {historyGrabbedData?.date && !historyFailedData?.date ? ( - } + tooltip={translate('GrabbedAt', { + date: formatDateTime( + historyGrabbedData.date, + longDateFormat, + timeFormat, + { includeSeconds: true } + ), + })} + kind={kinds.INVERSE} + position={tooltipPositions.LEFT} /> ) : null} {historyFailedData?.date ? ( - } + tooltip={translate('FailedAt', { + date: formatDateTime( + historyFailedData.date, + longDateFormat, + timeFormat, + { includeSeconds: true } + ), + })} + kind={kinds.INVERSE} + position={tooltipPositions.LEFT} /> ) : null} - {blocklistData?.date ? ( + {blocklistedData?.date ? ( ) : null} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRowConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchRowConnector.js deleted file mode 100644 index 22ebccd3d5..0000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchRowConnector.js +++ /dev/null @@ -1,62 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import InteractiveSearchRow from './InteractiveSearchRow'; - -function createMapStateToProps() { - return createSelector( - (state, { guid }) => guid, - (state) => state.movieHistory.items, - (state) => state.movieBlocklist.items, - (guid, movieHistory, movieBlocklist) => { - - let blocklistData = {}; - let historyFailedData = {}; - - const historyGrabbedData = movieHistory.find((movie) => movie.eventType === 'grabbed' && movie.data.guid === guid); - if (historyGrabbedData) { - historyFailedData = movieHistory.find((movie) => movie.eventType === 'downloadFailed' && movie.sourceTitle === historyGrabbedData.sourceTitle); - blocklistData = movieBlocklist.find((item) => item.sourceTitle === historyGrabbedData.sourceTitle); - } - - return { - historyGrabbedData, - historyFailedData, - blocklistData - }; - } - ); -} - -class InteractiveSearchRowConnector extends Component { - - // - // Render - - render() { - const { - historyGrabbedData, - historyFailedData, - blocklistData, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -InteractiveSearchRowConnector.propTypes = { - historyGrabbedData: PropTypes.object, - historyFailedData: PropTypes.object, - blocklistData: PropTypes.object -}; - -export default connect(createMapStateToProps)(InteractiveSearchRowConnector); diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/InteractiveSearch/Peers.tsx similarity index 68% rename from frontend/src/InteractiveSearch/Peers.js rename to frontend/src/InteractiveSearch/Peers.tsx index a55e75c092..9c0caca582 100644 --- a/frontend/src/InteractiveSearch/Peers.js +++ b/frontend/src/InteractiveSearch/Peers.tsx @@ -1,9 +1,8 @@ -import PropTypes from 'prop-types'; import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; -function getKind(seeders) { +function getKind(seeders: number = 0) { if (seeders > 50) { return kinds.PRIMARY; } @@ -19,7 +18,7 @@ function getKind(seeders) { return kinds.DANGER; } -function getPeersTooltipPart(peers, peersUnit) { +function getPeersTooltipPart(peers: number | undefined, peersUnit: string) { if (peers == null) { return `Unknown ${peersUnit}s`; } @@ -31,27 +30,27 @@ function getPeersTooltipPart(peers, peersUnit) { return `${peers} ${peersUnit}s`; } -function Peers(props) { - const { - seeders, - leechers - } = props; +interface PeersProps { + seeders?: number; + leechers?: number; +} + +function Peers(props: PeersProps) { + const { seeders, leechers } = props; const kind = getKind(seeders); return ( ); } -Peers.propTypes = { - seeders: PropTypes.number, - leechers: PropTypes.number -}; - export default Peers; diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js index 417e0bfe1a..a3af095f9b 100644 --- a/frontend/src/Movie/Details/MovieDetailsConnector.js +++ b/frontend/src/Movie/Details/MovieDetailsConnector.js @@ -8,11 +8,9 @@ import * as commandNames from 'Commands/commandNames'; import { executeCommand } from 'Store/Actions/commandActions'; import { clearExtraFiles, fetchExtraFiles } from 'Store/Actions/extraFileActions'; import { toggleMovieMonitored } from 'Store/Actions/movieActions'; -import { clearMovieBlocklist, fetchMovieBlocklist } from 'Store/Actions/movieBlocklistActions'; import { clearMovieCredits, fetchMovieCredits } from 'Store/Actions/movieCreditsActions'; import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; import { fetchImportListSchema } from 'Store/Actions/settingsActions'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; @@ -188,12 +186,6 @@ function createMapDispatchToProps(dispatch, props) { dispatchClearExtraFiles() { dispatch(clearExtraFiles()); }, - dispatchClearReleases() { - dispatch(clearReleases()); - }, - dispatchCancelFetchReleases() { - dispatch(cancelFetchReleases()); - }, dispatchFetchQueueDetails({ movieId }) { dispatch(fetchQueueDetails({ movieId })); }, @@ -211,12 +203,6 @@ function createMapDispatchToProps(dispatch, props) { }, onGoToMovie(titleSlug) { dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`)); - }, - dispatchFetchMovieBlocklist({ movieId }) { - dispatch(fetchMovieBlocklist({ movieId })); - }, - dispatchClearMovieBlocklist() { - dispatch(clearMovieBlocklist()); } }; } @@ -270,7 +256,6 @@ class MovieDetailsConnector extends Component { const movieId = this.props.id; this.props.dispatchFetchMovieFiles({ movieId }); - this.props.dispatchFetchMovieBlocklist({ movieId }); this.props.dispatchFetchExtraFiles({ movieId }); this.props.dispatchFetchMovieCredits({ movieId }); this.props.dispatchFetchQueueDetails({ movieId }); @@ -278,13 +263,10 @@ class MovieDetailsConnector extends Component { }; unpopulate = () => { - this.props.dispatchCancelFetchReleases(); - this.props.dispatchClearMovieBlocklist(); this.props.dispatchClearMovieFiles(); this.props.dispatchClearExtraFiles(); this.props.dispatchClearMovieCredits(); this.props.dispatchClearQueueDetails(); - this.props.dispatchClearReleases(); }; // @@ -341,15 +323,11 @@ MovieDetailsConnector.propTypes = { dispatchClearExtraFiles: PropTypes.func.isRequired, dispatchFetchMovieCredits: PropTypes.func.isRequired, dispatchClearMovieCredits: PropTypes.func.isRequired, - dispatchClearReleases: PropTypes.func.isRequired, - dispatchCancelFetchReleases: PropTypes.func.isRequired, dispatchToggleMovieMonitored: PropTypes.func.isRequired, dispatchFetchQueueDetails: PropTypes.func.isRequired, dispatchClearQueueDetails: PropTypes.func.isRequired, dispatchFetchImportListSchema: PropTypes.func.isRequired, dispatchExecuteCommand: PropTypes.func.isRequired, - dispatchFetchMovieBlocklist: PropTypes.func.isRequired, - dispatchClearMovieBlocklist: PropTypes.func.isRequired, onGoToMovie: PropTypes.func.isRequired }; diff --git a/frontend/src/Movie/Search/MovieInteractiveSearchModal.tsx b/frontend/src/Movie/Search/MovieInteractiveSearchModal.tsx index 5a4fb3a098..511a66e5bb 100644 --- a/frontend/src/Movie/Search/MovieInteractiveSearchModal.tsx +++ b/frontend/src/Movie/Search/MovieInteractiveSearchModal.tsx @@ -2,6 +2,8 @@ import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import Modal from 'Components/Modal/Modal'; import { sizes } from 'Helpers/Props'; +import { clearMovieBlocklist } from 'Store/Actions/movieBlocklistActions'; +import { clearMovieHistory } from 'Store/Actions/movieHistoryActions'; import { cancelFetchReleases, clearReleases, @@ -24,6 +26,9 @@ function MovieInteractiveSearchModal(props: MovieInteractiveSearchModalProps) { dispatch(cancelFetchReleases()); dispatch(clearReleases()); + dispatch(clearMovieBlocklist()); + dispatch(clearMovieHistory()); + onModalClose(); }, [dispatch, onModalClose]); diff --git a/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.tsx b/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.tsx index a5a9db2e22..c9f4c7a7b1 100644 --- a/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.tsx +++ b/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.tsx @@ -6,7 +6,9 @@ import ModalContent from 'Components/Modal/ModalContent'; import ModalFooter from 'Components/Modal/ModalFooter'; import ModalHeader from 'Components/Modal/ModalHeader'; import { scrollDirections } from 'Helpers/Props'; -import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector'; +import InteractiveSearch from 'InteractiveSearch/InteractiveSearch'; +import { clearMovieBlocklist } from 'Store/Actions/movieBlocklistActions'; +import { clearMovieHistory } from 'Store/Actions/movieHistoryActions'; import { cancelFetchReleases, clearReleases, @@ -30,6 +32,9 @@ function MovieInteractiveSearchModalContent( return () => { dispatch(cancelFetchReleases()); dispatch(clearReleases()); + + dispatch(clearMovieBlocklist()); + dispatch(clearMovieHistory()); }; }, [dispatch]); @@ -44,7 +49,7 @@ function MovieInteractiveSearchModalContent( - + diff --git a/frontend/src/typings/Release.ts b/frontend/src/typings/Release.ts new file mode 100644 index 0000000000..ec0dffaf67 --- /dev/null +++ b/frontend/src/typings/Release.ts @@ -0,0 +1,35 @@ +import type DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import Language from 'Language/Language'; +import { QualityModel } from 'Quality/Quality'; +import CustomFormat from 'typings/CustomFormat'; + +interface Release { + guid: string; + protocol: DownloadProtocol; + age: number; + ageHours: number; + ageMinutes: number; + publishDate: string; + title: string; + infoUrl: string; + indexerId: number; + indexer: string; + size: number; + seeders?: number; + leechers?: number; + quality: QualityModel; + languages: Language[]; + customFormats: CustomFormat[]; + customFormatScore: number; + mappedMovieId?: number; + indexerFlags: string[]; + rejections: string[]; + movieRequested: boolean; + downloadAllowed: boolean; + + isGrabbing?: boolean; + isGrabbed?: boolean; + grabError?: string; +} + +export default Release; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index d2c29ed270..77a1a701a2 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -159,6 +159,7 @@ "BlocklistReleaseHelpText": "Blocks this release from being redownloaded by {appName} via RSS or Automatic Search", "BlocklistReleases": "Blocklist Releases", "Blocklisted": "Blocklisted", + "BlocklistedAt": "Blocklisted at {date}", "Branch": "Branch", "BranchUpdate": "Branch to use to update {appName}", "BranchUpdateMechanism": "Branch used by external update mechanism", @@ -645,6 +646,7 @@ "ExtraFileExtensionsHelpText": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)", "ExtraFileExtensionsHelpTextsExamples": "Examples: '.sub, .nfo' or 'sub,nfo'", "Failed": "Failed", + "FailedAt": "Failed at {date}", "FailedDownloadHandling": "Failed Download Handling", "FailedLoadingSearchResults": "Failed to load search results, please try again.", "FailedToFetchUpdates": "Failed to fetch updates", @@ -708,6 +710,7 @@ "GrabReleaseMessageText": "{appName} was unable to determine which movie this release was for. {appName} may be unable to automatically import this release. Do you want to grab '{0}'?", "GrabSelected": "Grab Selected", "Grabbed": "Grabbed", + "GrabbedAt": "Grabbed at {date}", "Group": "Group", "HardlinkCopyFiles": "Hardlink/Copy Files", "HaveNotAddedMovies": "You haven't added any movies yet, do you want to import some or all of your movies first?",