diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index f61d87e18..3e23005f9 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -4,7 +4,8 @@ import { Redirect, Route } from 'react-router-dom'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import HistoryConnector from 'History/HistoryConnector'; -import MovieIndexConnector from 'Indexer/Index/MovieIndexConnector'; +import IndexerIndexConnector from 'Indexer/Index/IndexerIndexConnector'; +import SearchConnector from 'Search/SearchConnector'; import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector'; import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector'; import NotificationSettings from 'Settings/Notifications/NotificationSettings'; @@ -33,7 +34,7 @@ function AppRoutes(props) { { @@ -55,7 +56,7 @@ function AppRoutes(props) { {/* @@ -64,7 +65,7 @@ function AppRoutes(props) { {/* diff --git a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js index 0132ae641..f9b7b7e95 100644 --- a/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js +++ b/frontend/src/Components/Filter/Builder/IndexerFilterBuilderRowValueConnector.js @@ -3,12 +3,12 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import tagShape from 'Helpers/Props/Shapes/tagShape'; -import { fetchIndexers } from 'Store/Actions/settingsActions'; +import { fetchIndexers } from 'Store/Actions/indexerActions'; import FilterBuilderRowValue from './FilterBuilderRowValue'; function createMapStateToProps() { return createSelector( - (state) => state.settings.indexers, + (state) => state.indexers, (qualityProfiles) => { const { isFetching, diff --git a/frontend/src/Components/Page/Header/MovieSearchResult.js b/frontend/src/Components/Page/Header/MovieSearchResult.js index c0eaa568d..b5d11b74f 100644 --- a/frontend/src/Components/Page/Header/MovieSearchResult.js +++ b/frontend/src/Components/Page/Header/MovieSearchResult.js @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import Label from 'Components/Label'; import { kinds } from 'Helpers/Props'; -import MoviePoster from 'Indexer/MoviePoster'; import styles from './MovieSearchResult.css'; function MovieSearchResult(props) { @@ -10,7 +9,6 @@ function MovieSearchResult(props) { match, title, year, - images, alternateTitles, tags } = props; @@ -26,14 +24,6 @@ function MovieSearchResult(props) { return (
- -
{title} { year > 0 ? `(${year})` : ''} @@ -67,7 +57,6 @@ function MovieSearchResult(props) { MovieSearchResult.propTypes = { title: PropTypes.string.isRequired, year: PropTypes.number.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired, tags: PropTypes.arrayOf(PropTypes.object).isRequired, match: PropTypes.object.isRequired diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 67dd70538..baf2a21dd 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -5,6 +5,7 @@ import { withRouter } from 'react-router-dom'; import { createSelector } from 'reselect'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; +import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchIndexerFlags, fetchLanguages, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; @@ -46,6 +47,7 @@ const selectIsPopulated = createSelector( (state) => state.tags.isPopulated, (state) => state.settings.ui.isPopulated, (state) => state.settings.languages.isPopulated, + (state) => state.indexers.isPopulated, (state) => state.settings.indexerFlags.isPopulated, (state) => state.system.status.isPopulated, ( @@ -53,6 +55,7 @@ const selectIsPopulated = createSelector( tagsIsPopulated, uiSettingsIsPopulated, languagesIsPopulated, + indexersIsPopulated, indexerFlagsIsPopulated, systemStatusIsPopulated ) => { @@ -61,6 +64,7 @@ const selectIsPopulated = createSelector( tagsIsPopulated && uiSettingsIsPopulated && languagesIsPopulated && + indexersIsPopulated && indexerFlagsIsPopulated && systemStatusIsPopulated ); @@ -72,6 +76,7 @@ const selectErrors = createSelector( (state) => state.tags.error, (state) => state.settings.ui.error, (state) => state.settings.languages.error, + (state) => state.indexers.error, (state) => state.settings.indexerFlags.error, (state) => state.system.status.error, ( @@ -79,6 +84,7 @@ const selectErrors = createSelector( tagsError, uiSettingsError, languagesError, + indexersError, indexerFlagsError, systemStatusError ) => { @@ -87,6 +93,7 @@ const selectErrors = createSelector( tagsError || uiSettingsError || languagesError || + indexersError || indexerFlagsError || systemStatusError ); @@ -97,6 +104,7 @@ const selectErrors = createSelector( tagsError, uiSettingsError, languagesError, + indexersError, indexerFlagsError, systemStatusError }; @@ -139,6 +147,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchLanguages() { dispatch(fetchLanguages()); }, + dispatchFetchIndexers() { + dispatch(fetchIndexers()); + }, dispatchFetchIndexerFlags() { dispatch(fetchIndexerFlags()); }, @@ -175,6 +186,7 @@ class PageConnector extends Component { this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchTags(); this.props.dispatchFetchLanguages(); + this.props.dispatchFetchIndexers(); this.props.dispatchFetchIndexerFlags(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); @@ -197,6 +209,7 @@ class PageConnector extends Component { hasError, dispatchFetchTags, dispatchFetchLanguages, + dispatchFetchIndexers, dispatchFetchIndexerFlags, dispatchFetchUISettings, dispatchFetchStatus, @@ -234,6 +247,7 @@ PageConnector.propTypes = { dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchLanguages: PropTypes.func.isRequired, + dispatchFetchIndexers: PropTypes.func.isRequired, dispatchFetchIndexerFlags: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 195e99e39..9e5bc8277 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -6,7 +6,7 @@ import { createSelector } from 'reselect'; import { setAppValue, setVersion } from 'Store/Actions/appActions'; import { removeItem, update, updateItem } from 'Store/Actions/baseActions'; import { fetchCommands, finishCommand, updateCommand } from 'Store/Actions/commandActions'; -import { fetchMovies } from 'Store/Actions/movieActions'; +import { fetchIndexers } from 'Store/Actions/indexerActions'; import { fetchHealth } from 'Store/Actions/systemActions'; import { fetchTagDetails, fetchTags } from 'Store/Actions/tagActions'; import { repopulatePage } from 'Utilities/pagePopulator'; @@ -42,7 +42,7 @@ const mapDispatchToProps = { dispatchUpdateItem: updateItem, dispatchRemoveItem: removeItem, dispatchFetchHealth: fetchHealth, - dispatchFetchMovies: fetchMovies, + dispatchFetchIndexers: fetchIndexers, dispatchFetchTags: fetchTags, dispatchFetchTagDetails: fetchTagDetails }; @@ -225,7 +225,7 @@ class SignalRConnector extends Component { const { dispatchFetchCommands, - dispatchFetchMovies, + dispatchFetchIndexers, dispatchSetAppValue } = this.props; @@ -238,7 +238,7 @@ class SignalRConnector extends Component { // Repopulate the page (if a repopulator is set) to ensure things // are in sync after reconnecting. - dispatchFetchMovies(); + dispatchFetchIndexers(); dispatchFetchCommands(); repopulatePage(); } @@ -273,7 +273,7 @@ SignalRConnector.propTypes = { dispatchUpdateItem: PropTypes.func.isRequired, dispatchRemoveItem: PropTypes.func.isRequired, dispatchFetchHealth: PropTypes.func.isRequired, - dispatchFetchMovies: PropTypes.func.isRequired, + dispatchFetchIndexers: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchTagDetails: PropTypes.func.isRequired }; diff --git a/frontend/src/History/HistoryRowConnector.js b/frontend/src/History/HistoryRowConnector.js index b68c0ba4c..05dc65988 100644 --- a/frontend/src/History/HistoryRowConnector.js +++ b/frontend/src/History/HistoryRowConnector.js @@ -3,13 +3,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import HistoryRow from './HistoryRow'; function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), createUISettingsSelector(), (movie, uiSettings) => { return { diff --git a/frontend/src/Indexer/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Indexer/Delete/DeleteMovieModalContentConnector.js index be9dd10ee..1bfb3c961 100644 --- a/frontend/src/Indexer/Delete/DeleteMovieModalContentConnector.js +++ b/frontend/src/Indexer/Delete/DeleteMovieModalContentConnector.js @@ -4,12 +4,12 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { deleteMovie } from 'Store/Actions/movieActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import DeleteMovieModalContent from './DeleteMovieModalContent'; function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), (movie) => { return movie; } diff --git a/frontend/src/Indexer/Edit/EditMovieModalContentConnector.js b/frontend/src/Indexer/Edit/EditMovieModalContentConnector.js index 49f5aa11e..ef5e4cc74 100644 --- a/frontend/src/Indexer/Edit/EditMovieModalContentConnector.js +++ b/frontend/src/Indexer/Edit/EditMovieModalContentConnector.js @@ -3,14 +3,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { saveMovie, setMovieValue } from 'Store/Actions/movieActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import selectSettings from 'Store/Selectors/selectSettings'; import EditMovieModalContent from './EditMovieModalContent'; function createIsPathChangingSelector() { return createSelector( (state) => state.movies.pendingChanges, - createMovieSelector(), + createIndexerSelector(), (pendingChanges, movie) => { const path = pendingChanges.path; @@ -26,7 +26,7 @@ function createIsPathChangingSelector() { function createMapStateToProps() { return createSelector( (state) => state.movies, - createMovieSelector(), + createIndexerSelector(), createIsPathChangingSelector(), (moviesState, movie, isPathChanging) => { const { diff --git a/frontend/src/Indexer/Editor/Delete/DeleteMovieModalContentConnector.js b/frontend/src/Indexer/Editor/Delete/DeleteMovieModalContentConnector.js index d3d26105b..7bb7d3bf3 100644 --- a/frontend/src/Indexer/Editor/Delete/DeleteMovieModalContentConnector.js +++ b/frontend/src/Indexer/Editor/Delete/DeleteMovieModalContentConnector.js @@ -1,7 +1,7 @@ import _ from 'lodash'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { bulkDeleteMovie } from 'Store/Actions/movieIndexActions'; +import { bulkDeleteMovie } from 'Store/Actions/indexerIndexActions'; import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import DeleteMovieModalContent from './DeleteMovieModalContent'; diff --git a/frontend/src/Indexer/Editor/MovieEditorFooter.js b/frontend/src/Indexer/Editor/MovieEditorFooter.js index d009d83ab..65707706f 100644 --- a/frontend/src/Indexer/Editor/MovieEditorFooter.js +++ b/frontend/src/Indexer/Editor/MovieEditorFooter.js @@ -1,7 +1,5 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import AvailabilitySelectInput from 'Components/Form/AvailabilitySelectInput'; -import SelectInput from 'Components/Form/SelectInput'; import SpinnerButton from 'Components/Link/SpinnerButton'; import PageContentFooter from 'Components/Page/PageContentFooter'; import { kinds } from 'Helpers/Props'; @@ -22,10 +20,6 @@ class MovieEditorFooter extends Component { super(props, context); this.state = { - monitored: NO_CHANGE, - qualityProfileId: NO_CHANGE, - minimumAvailability: NO_CHANGE, - rootFolderPath: NO_CHANGE, savingTags: false, isDeleteMovieModalOpen: false, isTagsModalOpen: false @@ -40,10 +34,6 @@ class MovieEditorFooter extends Component { if (prevProps.isSaving && !isSaving && !saveError) { this.setState({ - monitored: NO_CHANGE, - qualityProfileId: NO_CHANGE, - minimumAvailability: NO_CHANGE, - rootFolderPath: NO_CHANGE, savingTags: false }); } @@ -60,15 +50,6 @@ class MovieEditorFooter extends Component { } switch (name) { - case 'rootFolderPath': - this.setState({ - isConfirmMoveModalOpen: true, - destinationRootFolder: value - }); - break; - case 'monitored': - this.props.onSaveSelected({ [name]: value === 'monitored' }); - break; default: this.props.onSaveSelected({ [name]: value }); } @@ -102,15 +83,6 @@ class MovieEditorFooter extends Component { this.setState({ isTagsModalOpen: false }); } - onSaveRootFolderPress = () => { - this.setState({ - isConfirmMoveModalOpen: false, - destinationRootFolder: null - }); - - this.props.onSaveSelected({ rootFolderPath: this.state.destinationRootFolder }); - } - // // Render @@ -119,96 +91,30 @@ class MovieEditorFooter extends Component { movieIds, selectedCount, isSaving, - isDeleting, - isOrganizingMovie, - onOrganizeMoviePress + isDeleting } = this.props; const { - monitored, - qualityProfileId, - minimumAvailability, - rootFolderPath, savingTags, isTagsModalOpen, isDeleteMovieModalOpen } = this.state; - const monitoredOptions = [ - { key: NO_CHANGE, value: 'No Change', disabled: true }, - { key: 'monitored', value: 'Monitored' }, - { key: 'unmonitored', value: 'Unmonitored' } - ]; - return ( -
- - - -
- -
- -
- -
- - - -
- -
- -
-
- - {translate('RenameFiles')} - - {translate('SetTags')} @@ -252,7 +158,6 @@ MovieEditorFooter.propTypes = { saveError: PropTypes.object, isDeleting: PropTypes.bool.isRequired, deleteError: PropTypes.object, - isOrganizingMovie: PropTypes.bool.isRequired, onSaveSelected: PropTypes.func.isRequired, onOrganizeMoviePress: PropTypes.func.isRequired }; diff --git a/frontend/src/Indexer/History/MovieHistoryRowConnector.js b/frontend/src/Indexer/History/MovieHistoryRowConnector.js index c8f8bb501..6d34226e8 100644 --- a/frontend/src/Indexer/History/MovieHistoryRowConnector.js +++ b/frontend/src/Indexer/History/MovieHistoryRowConnector.js @@ -1,13 +1,13 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import MovieHistoryRow from './MovieHistoryRow'; function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), createUISettingsSelector(), (movie, uiSettings) => { return { diff --git a/frontend/src/Indexer/Index/MovieIndex.css b/frontend/src/Indexer/Index/IndexerIndex.css similarity index 100% rename from frontend/src/Indexer/Index/MovieIndex.css rename to frontend/src/Indexer/Index/IndexerIndex.css diff --git a/frontend/src/Indexer/Index/MovieIndex.js b/frontend/src/Indexer/Index/IndexerIndex.js similarity index 68% rename from frontend/src/Indexer/Index/MovieIndex.js rename to frontend/src/Indexer/Index/IndexerIndex.js index 4f7ba06dc..956c3b2c7 100644 --- a/frontend/src/Indexer/Index/MovieIndex.js +++ b/frontend/src/Indexer/Index/IndexerIndex.js @@ -2,7 +2,6 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import PageJumpBar from 'Components/Page/PageJumpBar'; @@ -11,10 +10,11 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, kinds, sortDirections } from 'Helpers/Props'; +import { align, icons, sortDirections } from 'Helpers/Props'; import MovieEditorFooter from 'Indexer/Editor/MovieEditorFooter.js'; -import OrganizeMovieModal from 'Indexer/Editor/Organize/OrganizeMovieModal'; -import NoMovie from 'Indexer/NoMovie'; +import NoIndexer from 'Indexer/NoIndexer'; +import AddIndexerModal from 'Settings/Indexers/Indexers/AddIndexerModal'; +import EditIndexerModalConnector from 'Settings/Indexers/Indexers/EditIndexerModalConnector'; import * as keyCodes from 'Utilities/Constants/keyCodes'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; @@ -27,13 +27,13 @@ import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; import MovieIndexFooterConnector from './MovieIndexFooterConnector'; import MovieIndexTableConnector from './Table/MovieIndexTableConnector'; import MovieIndexTableOptionsConnector from './Table/MovieIndexTableOptionsConnector'; -import styles from './MovieIndex.css'; +import styles from './IndexerIndex.css'; -function getViewComponent(view) { +function getViewComponent() { return MovieIndexTableConnector; } -class MovieIndex extends Component { +class IndexerIndex extends Component { // // Lifecycle @@ -45,11 +45,9 @@ class MovieIndex extends Component { scroller: null, jumpBarItems: { order: [] }, jumpToCharacter: null, - isPosterOptionsModalOpen: false, - isOverviewOptionsModalOpen: false, isMovieEditorActive: false, - isOrganizingMovieModalOpen: false, - isConfirmSearchModalOpen: false, + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: false, searchType: null, allSelected: false, allUnselected: false, @@ -191,20 +189,19 @@ class MovieIndex extends Component { // // Listeners - onPosterOptionsPress = () => { - this.setState({ isPosterOptionsModalOpen: true }); + onAddIndexerPress = () => { + this.setState({ isAddIndexerModalOpen: true }); } - onPosterOptionsModalClose = () => { - this.setState({ isPosterOptionsModalOpen: false }); + onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { + this.setState({ + isAddIndexerModalOpen: false, + isEditIndexerModalOpen: indexerSelected + }); } - onOverviewOptionsPress = () => { - this.setState({ isOverviewOptionsModalOpen: true }); - } - - onOverviewOptionsModalClose = () => { - this.setState({ isOverviewOptionsModalOpen: false }); + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); } onMovieEditorTogglePress = () => { @@ -254,41 +251,6 @@ class MovieIndex extends Component { }); } - onOrganizeMoviePress = () => { - this.setState({ isOrganizingMovieModalOpen: true }); - } - - onOrganizeMovieModalClose = (organized) => { - this.setState({ isOrganizingMovieModalOpen: false }); - - if (organized === true) { - this.onSelectAllChange({ value: false }); - } - } - - onSearchPress = () => { - this.setState({ isConfirmSearchModalOpen: true, searchType: 'moviesSearch' }); - } - - onRefreshMoviePress = () => { - const selectedMovieIds = this.getSelectedIds(); - const refreshIds = this.state.isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds : []; - - this.props.onRefreshMoviePress(refreshIds); - } - - onSearchConfirmed = () => { - const selectedMovieIds = this.getSelectedIds(); - const searchIds = this.state.isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds : this.props.items.map((m) => m.id); - - this.props.onSearchPress(this.state.searchType, searchIds); - this.setState({ isConfirmSearchModalOpen: false }); - } - - onConfirmSearchModalClose = () => { - this.setState({ isConfirmSearchModalOpen: false }); - } - // // Render @@ -305,11 +267,6 @@ class MovieIndex extends Component { customFilters, sortKey, sortDirection, - view, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isSearchingMovies, isSaving, saveError, isDeleting, @@ -317,10 +274,7 @@ class MovieIndex extends Component { onScroll, onSortSelect, onFilterSelect, - onViewSelect, - onRefreshMoviePress, - onRssSyncPress, - onSearchPress, + onTestAllPress, ...otherProps } = this.props; @@ -328,7 +282,8 @@ class MovieIndex extends Component { scroller, jumpBarItems, jumpToCharacter, - isConfirmSearchModalOpen, + isAddIndexerModalOpen, + isEditIndexerModalOpen, isMovieEditorActive, selectedState, allSelected, @@ -337,9 +292,9 @@ class MovieIndex extends Component { const selectedMovieIds = this.getSelectedIds(); - const ViewComponent = getViewComponent(view); + const ViewComponent = getViewComponent(); const isLoaded = !!(!error && isPopulated && items.length && scroller); - const hasNoMovie = !totalItems; + const hasNoIndexer = !totalItems; return ( @@ -349,18 +304,15 @@ class MovieIndex extends Component { label={'Add Indexer'} iconName={icons.ADD} spinningName={icons.ADD} - isSpinning={isRefreshingMovie} - isDisabled={hasNoMovie} - onPress={this.onRefreshMoviePress} + onPress={this.onAddIndexerPress} /> @@ -370,13 +322,13 @@ class MovieIndex extends Component { : } @@ -386,7 +338,7 @@ class MovieIndex extends Component { : null @@ -398,49 +350,25 @@ class MovieIndex extends Component { alignContent={align.RIGHT} collapseButtons={false} > - { - view === 'table' ? - - - : - null - } - { - view === 'posters' ? - : - null - } - - { - view === 'overview' ? - : - null - } + + + : + null @@ -448,7 +376,7 @@ class MovieIndex extends Component { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - isDisabled={hasNoMovie} + isDisabled={hasNoIndexer} onFilterSelect={onFilterSelect} /> @@ -458,7 +386,7 @@ class MovieIndex extends Component { { @@ -501,7 +429,7 @@ class MovieIndex extends Component { { !error && isPopulated && !items.length && - + } @@ -523,42 +451,26 @@ class MovieIndex extends Component { saveError={saveError} isDeleting={isDeleting} deleteError={deleteError} - isOrganizingMovie={isOrganizingMovie} onSaveSelected={this.onSaveSelected} onOrganizeMoviePress={this.onOrganizeMoviePress} /> } - - -
- Are you sure you want to perform mass movie search for {isMovieEditorActive && selectedMovieIds.length > 0 ? selectedMovieIds.length : this.props.items.length} movies? -
-
- This cannot be cancelled once started without restarting Prowlarr. -
-
- } - confirmLabel={translate('Search')} - onConfirm={this.onSearchConfirmed} - onCancel={this.onConfirmSearchModalClose} + ); } } -MovieIndex.propTypes = { +IndexerIndex.propTypes = { isFetching: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, @@ -570,11 +482,6 @@ MovieIndex.propTypes = { customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), - view: PropTypes.string.isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - isOrganizingMovie: PropTypes.bool.isRequired, - isSearchingMovies: PropTypes.bool.isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, @@ -582,12 +489,9 @@ MovieIndex.propTypes = { deleteError: PropTypes.object, onSortSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired, - onViewSelect: PropTypes.func.isRequired, - onRefreshMoviePress: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, + onTestAllPress: PropTypes.func.isRequired, onScroll: PropTypes.func.isRequired, onSaveSelected: PropTypes.func.isRequired }; -export default MovieIndex; +export default IndexerIndex; diff --git a/frontend/src/Indexer/Index/IndexerIndexConnector.js b/frontend/src/Indexer/Index/IndexerIndexConnector.js new file mode 100644 index 000000000..217643fb9 --- /dev/null +++ b/frontend/src/Indexer/Index/IndexerIndexConnector.js @@ -0,0 +1,89 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import withScrollPosition from 'Components/withScrollPosition'; +import { testAllIndexers } from 'Store/Actions/indexerActions'; +import { saveMovieEditor, setMovieFilter, setMovieSort, setMovieTableOption } from 'Store/Actions/indexerIndexActions'; +import scrollPositions from 'Store/scrollPositions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createIndexerClientSideCollectionItemsSelector from 'Store/Selectors/createIndexerClientSideCollectionItemsSelector'; +import IndexerIndex from './IndexerIndex'; + +function createMapStateToProps() { + return createSelector( + createIndexerClientSideCollectionItemsSelector('indexerIndex'), + createDimensionsSelector(), + ( + movies, + dimensionsState + ) => { + return { + ...movies, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setMovieTableOption(payload)); + }, + + onSortSelect(sortKey) { + dispatch(setMovieSort({ sortKey })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setMovieFilter({ selectedFilterKey })); + }, + + dispatchSaveMovieEditor(payload) { + dispatch(saveMovieEditor(payload)); + }, + + onTestAllPress() { + dispatch(testAllIndexers()); + } + }; +} + +class IndexerIndexConnector extends Component { + + // + // Listeners + + onSaveSelected = (payload) => { + this.props.dispatchSaveMovieEditor(payload); + } + + onScroll = ({ scrollTop }) => { + scrollPositions.movieIndex = scrollTop; + } + + // + // Render + + render() { + return ( + + ); + } +} + +IndexerIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + dispatchSaveMovieEditor: PropTypes.func.isRequired, + items: PropTypes.arrayOf(PropTypes.object) +}; + +export default withScrollPosition( + connect(createMapStateToProps, createMapDispatchToProps)(IndexerIndexConnector), + 'indexerIndex' +); diff --git a/frontend/src/Indexer/Index/MovieIndexConnector.js b/frontend/src/Indexer/Index/MovieIndexConnector.js deleted file mode 100644 index 9d57482b7..000000000 --- a/frontend/src/Indexer/Index/MovieIndexConnector.js +++ /dev/null @@ -1,133 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withScrollPosition from 'Components/withScrollPosition'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { saveMovieEditor, setMovieFilter, setMovieSort, setMovieTableOption, setMovieView } from 'Store/Actions/movieIndexActions'; -import scrollPositions from 'Store/scrollPositions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createMovieClientSideCollectionItemsSelector from 'Store/Selectors/createMovieClientSideCollectionItemsSelector'; -import MovieIndex from './MovieIndex'; - -function createMapStateToProps() { - return createSelector( - createMovieClientSideCollectionItemsSelector('movieIndex'), - createCommandExecutingSelector(commandNames.REFRESH_MOVIE), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createCommandExecutingSelector(commandNames.RENAME_MOVIE), - createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH), - createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH), - createDimensionsSelector(), - ( - movies, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isCutoffMoviesSearch, - isMissingMoviesSearch, - dimensionsState - ) => { - return { - ...movies, - isRefreshingMovie, - isRssSyncExecuting, - isOrganizingMovie, - isSearchingMovies: isCutoffMoviesSearch || isMissingMoviesSearch, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setMovieTableOption(payload)); - }, - - onSortSelect(sortKey) { - dispatch(setMovieSort({ sortKey })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setMovieFilter({ selectedFilterKey })); - }, - - dispatchSetMovieView(view) { - dispatch(setMovieView({ view })); - }, - - dispatchSaveMovieEditor(payload) { - dispatch(saveMovieEditor(payload)); - }, - - onRefreshMoviePress(items) { - dispatch(executeCommand({ - name: commandNames.REFRESH_MOVIE, - movieIds: items - })); - }, - - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchPress(command, items) { - dispatch(executeCommand({ - name: command, - movieIds: items - })); - } - }; -} - -class MovieIndexConnector extends Component { - - // - // Listeners - - onViewSelect = (view) => { - // Reset the scroll position before changing the view - this.props.dispatchSetMovieView(view); - } - - onSaveSelected = (payload) => { - this.props.dispatchSaveMovieEditor(payload); - } - - onScroll = ({ scrollTop }) => { - scrollPositions.movieIndex = scrollTop; - } - - // - // Render - - render() { - return ( - - ); - } -} - -MovieIndexConnector.propTypes = { - isSmallScreen: PropTypes.bool.isRequired, - view: PropTypes.string.isRequired, - dispatchSetMovieView: PropTypes.func.isRequired, - dispatchSaveMovieEditor: PropTypes.func.isRequired, - items: PropTypes.arrayOf(PropTypes.object) -}; - -export default withScrollPosition( - connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexConnector), - 'movieIndex' -); diff --git a/frontend/src/Indexer/Index/MovieIndexFilterModalConnector.js b/frontend/src/Indexer/Index/MovieIndexFilterModalConnector.js index ff95111d6..88279e906 100644 --- a/frontend/src/Indexer/Index/MovieIndexFilterModalConnector.js +++ b/frontend/src/Indexer/Index/MovieIndexFilterModalConnector.js @@ -1,17 +1,17 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import FilterModal from 'Components/Filter/FilterModal'; -import { setMovieFilter } from 'Store/Actions/movieIndexActions'; +import { setMovieFilter } from 'Store/Actions/indexerIndexActions'; function createMapStateToProps() { return createSelector( (state) => state.movies.items, - (state) => state.movieIndex.filterBuilderProps, + (state) => state.indexerIndex.filterBuilderProps, (sectionItems, filterBuilderProps) => { return { sectionItems, filterBuilderProps, - customFilterType: 'movieIndex' + customFilterType: 'indexerIndex' }; } ); diff --git a/frontend/src/Indexer/Index/MovieIndexFooter.js b/frontend/src/Indexer/Index/MovieIndexFooter.js index ad746d09a..2da89aa1c 100644 --- a/frontend/src/Indexer/Index/MovieIndexFooter.js +++ b/frontend/src/Indexer/Index/MovieIndexFooter.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './MovieIndexFooter.css'; @@ -17,8 +16,7 @@ class MovieIndexFooter extends PureComponent { const count = movies.length; let movieFiles = 0; - let monitored = 0; - let totalFileSize = 0; + let torrent = 0; movies.forEach((s) => { @@ -26,11 +24,9 @@ class MovieIndexFooter extends PureComponent { movieFiles += 1; } - if (s.monitored) { - monitored++; + if (s.protocol === 'torrent') { + torrent++; } - - totalFileSize += s.sizeOnDisk; }); return ( @@ -39,14 +35,14 @@ class MovieIndexFooter extends PureComponent {
- {translate('DownloadedAndMonitored')} + Enabled
- {translate('DownloadedButNotMonitored')} + Disabled
@@ -57,32 +53,7 @@ class MovieIndexFooter extends PureComponent { )} />
- {translate('MissingMonitoredAndConsideredAvailable')} -
-
- -
-
-
- {translate('MissingNotMonitored')} -
-
- -
-
-
- {translate('Queued')} -
-
- -
-
-
- {translate('Unreleased')} + Error
@@ -90,32 +61,25 @@ class MovieIndexFooter extends PureComponent {
- - - -
diff --git a/frontend/src/Indexer/Index/MovieIndexFooterConnector.js b/frontend/src/Indexer/Index/MovieIndexFooterConnector.js index ac017036f..b7d63cb1f 100644 --- a/frontend/src/Indexer/Index/MovieIndexFooterConnector.js +++ b/frontend/src/Indexer/Index/MovieIndexFooterConnector.js @@ -7,23 +7,17 @@ import MovieIndexFooter from './MovieIndexFooter'; function createUnoptimizedSelector() { return createSelector( - createClientSideCollectionSelector('movies', 'movieIndex'), - (movies) => { - return movies.items.map((s) => { + createClientSideCollectionSelector('indexers', 'indexerIndex'), + (indexers) => { + return indexers.items.map((s) => { const { - monitored, - status, - statistics, - sizeOnDisk, - hasFile + protocol, + privacy } = s; return { - monitored, - status, - statistics, - sizeOnDisk, - hasFile + protocol, + privacy }; }); } diff --git a/frontend/src/Indexer/Index/MovieIndexItemConnector.js b/frontend/src/Indexer/Index/MovieIndexItemConnector.js index 1acfc7349..6a573d3a6 100644 --- a/frontend/src/Indexer/Index/MovieIndexItemConnector.js +++ b/frontend/src/Indexer/Index/MovieIndexItemConnector.js @@ -5,20 +5,20 @@ import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import { executeCommand } from 'Store/Actions/commandActions'; import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; function selectShowSearchAction() { return createSelector( - (state) => state.movieIndex, - (movieIndex) => { - return movieIndex.tableOptions.showSearchAction; + (state) => state.indexerIndex, + (indexerIndex) => { + return indexerIndex.tableOptions.showSearchAction; } ); } function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), selectShowSearchAction(), createExecutingCommandsSelector(), ( diff --git a/frontend/src/Indexer/Index/Table/CapabilitiesLabel.js b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.js new file mode 100644 index 000000000..ae25ed058 --- /dev/null +++ b/frontend/src/Indexer/Index/Table/CapabilitiesLabel.js @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; + +function CapabilitiesLabel(props) { + const { + supportsBooks, + supportsMovies, + supportsMusic, + supportsTv + } = props; + + return ( + + { + supportsBooks ? + : + null + } + + { + supportsMovies ? + : + null + } + + { + supportsMusic ? + : + null + } + + { + supportsTv ? + : + null + } + + { + !supportsTv && !supportsMusic && !supportsMovies && !supportsBooks ? + : + null + } + + ); +} + +CapabilitiesLabel.propTypes = { + supportsTv: PropTypes.bool.isRequired, + supportsBooks: PropTypes.bool.isRequired, + supportsMusic: PropTypes.bool.isRequired, + supportsMovies: PropTypes.bool.isRequired +}; + +export default CapabilitiesLabel; diff --git a/frontend/src/Indexer/Index/Table/MovieIndexHeader.css b/frontend/src/Indexer/Index/Table/MovieIndexHeader.css index 5ed460254..77082acce 100644 --- a/frontend/src/Indexer/Index/Table/MovieIndexHeader.css +++ b/frontend/src/Indexer/Index/Table/MovieIndexHeader.css @@ -4,78 +4,31 @@ flex: 0 0 60px; } -.collection, -.sortTitle { +.name { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; flex: 4 0 110px; } -.minimumAvailability { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 140px; -} - -.studio { +.privacy, +.protocol { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - flex: 2 0 90px; + flex: 0 0 90px; } -.qualityProfileId { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 1 0 125px; -} - -.inCinemas, -.physicalRelease, -.digitalRelease, -.genres { +.capabilities { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; flex: 0 0 180px; } -.added, -.runtime { +.added { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; flex: 0 0 100px; } -.movieStatus, -.certification { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 100px; -} - -.year { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 80px; -} - -.path { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 1 0 150px; -} - -.sizeOnDisk { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 120px; -} - -.ratings { - composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; - - flex: 0 0 80px; -} - .tags { composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Indexer/Index/Table/MovieIndexHeaderConnector.js b/frontend/src/Indexer/Index/Table/MovieIndexHeaderConnector.js index d56915879..7361b0fef 100644 --- a/frontend/src/Indexer/Index/Table/MovieIndexHeaderConnector.js +++ b/frontend/src/Indexer/Index/Table/MovieIndexHeaderConnector.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux'; -import { setMovieTableOption } from 'Store/Actions/movieIndexActions'; +import { setMovieTableOption } from 'Store/Actions/indexerIndexActions'; import MovieIndexHeader from './MovieIndexHeader'; function createMapDispatchToProps(dispatch, props) { diff --git a/frontend/src/Indexer/Index/Table/MovieIndexRow.css b/frontend/src/Indexer/Index/Table/MovieIndexRow.css index 930984543..f28c01a81 100644 --- a/frontend/src/Indexer/Index/Table/MovieIndexRow.css +++ b/frontend/src/Indexer/Index/Table/MovieIndexRow.css @@ -11,78 +11,31 @@ flex: 0 0 60px; } -.collection, -.sortTitle { +.name { composes: cell; flex: 4 0 110px; } -.minimumAvailability { +.protocol, +.privacy { composes: cell; - flex: 0 0 140px; + flex: 0 0 90px; } -.studio { - composes: cell; - - flex: 2 0 90px; -} - -.qualityProfileId { - composes: cell; - - flex: 1 0 125px; -} - -.inCinemas, -.physicalRelease, -.digitalRelease, -.genres { +.capabilities { composes: cell; flex: 0 0 180px; } -.added, -.runtime { +.added { composes: cell; flex: 0 0 100px; } -.movieStatus, -.certification { - composes: cell; - - flex: 0 0 100px; -} - -.year { - composes: cell; - - flex: 0 0 80px; -} - -.path { - composes: cell; - - flex: 1 0 150px; -} - -.sizeOnDisk { - composes: cell; - - flex: 0 0 120px; -} - -.ratings { - composes: cell; - - flex: 0 0 80px; -} - .tags { composes: cell; diff --git a/frontend/src/Indexer/Index/Table/MovieIndexRow.js b/frontend/src/Indexer/Index/Table/MovieIndexRow.js index 18b35e8cb..0d60a7f21 100644 --- a/frontend/src/Indexer/Index/Table/MovieIndexRow.js +++ b/frontend/src/Indexer/Index/Table/MovieIndexRow.js @@ -1,24 +1,16 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import HeartRating from 'Components/HeartRating'; -import Icon from 'Components/Icon'; +import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell'; -import TagListConnector from 'Components/TagListConnector'; -import Tooltip from 'Components/Tooltip/Tooltip'; import { icons, kinds } from 'Helpers/Props'; import DeleteMovieModal from 'Indexer/Delete/DeleteMovieModal'; -import EditMovieModalConnector from 'Indexer/Edit/EditMovieModalConnector'; -import MovieFileStatusConnector from 'Indexer/MovieFileStatusConnector'; -import MovieTitleLink from 'Indexer/MovieTitleLink'; -import formatRuntime from 'Utilities/Date/formatRuntime'; -import formatBytes from 'Utilities/Number/formatBytes'; -import titleCase from 'Utilities/String/titleCase'; +import EditIndexerModalConnector from 'Settings/Indexers/Indexers/EditIndexerModalConnector'; import translate from 'Utilities/String/translate'; -import MovieStatusCell from './MovieStatusCell'; +import CapabilitiesLabel from './CapabilitiesLabel'; +import ProtocolLabel from './ProtocolLabel'; import styles from './MovieIndexRow.css'; class MovieIndexRow extends Component { @@ -30,22 +22,22 @@ class MovieIndexRow extends Component { super(props, context); this.state = { - isEditMovieModalOpen: false, + isEditIndexerModalOpen: false, isDeleteMovieModalOpen: false }; } - onEditMoviePress = () => { - this.setState({ isEditMovieModalOpen: true }); + onEditIndexerPress = () => { + this.setState({ isEditIndexerModalOpen: true }); } - onEditMovieModalClose = () => { - this.setState({ isEditMovieModalOpen: false }); + onEditIndexerModalClose = () => { + this.setState({ isEditIndexerModalOpen: false }); } onDeleteMoviePress = () => { this.setState({ - isEditMovieModalOpen: false, + isEditIndexerModalOpen: false, isDeleteMovieModalOpen: true }); } @@ -65,42 +57,25 @@ class MovieIndexRow extends Component { render() { const { id, - monitored, - status, - title, - titleSlug, - collection, - studio, - qualityProfile, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + protocol, + privacy, added, - year, - inCinemas, - physicalRelease, - digitalRelease, - runtime, - minimumAvailability, - path, - sizeOnDisk, - genres, - ratings, - certification, - tags, - showSearchAction, + supportsTv, + supportsBooks, + supportsMusic, + supportsMovies, columns, - isRefreshingMovie, - isSearchingMovie, isMovieEditorActive, isSelected, - onRefreshMoviePress, - onSearchPress, - onSelectedChange, - queueStatus, - queueState, - movieRuntimeFormat + onSelectedChange } = this.props; const { - isEditMovieModalOpen, + isEditIndexerModalOpen, isDeleteMovieModalOpen } = this.state; @@ -109,7 +84,6 @@ class MovieIndexRow extends Component { { columns.map((column) => { const { - name, isVisible } = column; @@ -117,12 +91,12 @@ class MovieIndexRow extends Component { return null; } - if (isMovieEditorActive && name === 'select') { + if (isMovieEditorActive && column.name === 'select') { return ( + + { + enableRss || enableAutomaticSearch || enableInteractiveSearch ? + : + null + } + { + !enableRss && !enableAutomaticSearch && !enableInteractiveSearch ? + : + null + } + ); } - if (name === 'sortTitle') { + if (column.name === 'name') { return ( + {name} + + ); + } - + + + ); + } + + if (column.name === 'protocol') { + return ( + + - ); } - if (name === 'collection') { + if (column.name === 'capabilities') { return ( - {collection ? collection.name : null } + ); } - if (name === 'studio') { - return ( - - {studio} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} - - ); - } - - if (name === 'added') { + if (column.name === 'added') { return ( ); } - if (name === 'year') { + if (column.name === 'actions') { return ( - {year} - - ); - } - - if (name === 'inCinemas') { - return ( - - ); - } - - if (name === 'digitalRelease') { - return ( - - ); - } - - if (name === 'physicalRelease') { - return ( - - ); - } - - if (name === 'runtime') { - return ( - - {formatRuntime(runtime, movieRuntimeFormat)} - - ); - } - - if (name === 'minimumAvailability') { - return ( - - {titleCase(minimumAvailability)} - - ); - } - - if (name === 'path') { - return ( - - {path} - - ); - } - - if (name === 'sizeOnDisk') { - return ( - - {formatBytes(sizeOnDisk)} - - ); - } - - if (name === 'genres') { - const joinedGenres = genres.join(', '); - - return ( - - - {joinedGenres} - - - ); - } - - if (name === 'movieStatus') { - return ( - - - - ); - } - - if (name === 'ratings') { - return ( - - - - ); - } - - if (name === 'certification') { - return ( - - {certification} - - ); - } - - if (name === 'tags') { - return ( - - - - ); - } - - if (name === 'actions') { - return ( - - - - } - canFlip={true} - kind={kinds.INVERSE} - /> - - - - - { - showSearchAction && - - } ); @@ -408,11 +219,10 @@ class MovieIndexRow extends Component { }) } - state.app.dimensions, - (state) => state.movieIndex.tableOptions, - (state) => state.movieIndex.columns, + (state) => state.indexerIndex.tableOptions, + (state) => state.indexerIndex.columns, (state) => state.settings.ui.item.movieRuntimeFormat, (dimensions, tableOptions, columns, movieRuntimeFormat) => { return { diff --git a/frontend/src/Indexer/Index/Table/MovieIndexTableOptionsConnector.js b/frontend/src/Indexer/Index/Table/MovieIndexTableOptionsConnector.js index 018a98678..c4a40a92f 100644 --- a/frontend/src/Indexer/Index/Table/MovieIndexTableOptionsConnector.js +++ b/frontend/src/Indexer/Index/Table/MovieIndexTableOptionsConnector.js @@ -4,7 +4,7 @@ import MovieIndexTableOptions from './MovieIndexTableOptions'; function createMapStateToProps() { return createSelector( - (state) => state.movieIndex.tableOptions, + (state) => state.indexerIndex.tableOptions, (tableOptions) => { return tableOptions; } diff --git a/frontend/src/Indexer/Index/Table/MovieStatusCell.css b/frontend/src/Indexer/Index/Table/MovieStatusCell.css deleted file mode 100644 index fbcd5eee9..000000000 --- a/frontend/src/Indexer/Index/Table/MovieStatusCell.css +++ /dev/null @@ -1,9 +0,0 @@ -.status { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; - - width: 60px; -} - -.statusIcon { - width: 20px !important; -} diff --git a/frontend/src/Indexer/Index/Table/MovieStatusCell.js b/frontend/src/Indexer/Index/Table/MovieStatusCell.js deleted file mode 100644 index 24595ac60..000000000 --- a/frontend/src/Indexer/Index/Table/MovieStatusCell.js +++ /dev/null @@ -1,54 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; -import { icons } from 'Helpers/Props'; -import { getMovieStatusDetails } from 'Indexer/MovieStatus'; -import translate from 'Utilities/String/translate'; -import styles from './MovieStatusCell.css'; - -function MovieStatusCell(props) { - const { - className, - monitored, - status, - component: Component, - ...otherProps - } = props; - - const statusDetails = getMovieStatusDetails(status); - - return ( - - - - - - - ); -} - -MovieStatusCell.propTypes = { - className: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - component: PropTypes.elementType -}; - -MovieStatusCell.defaultProps = { - className: styles.status, - component: VirtualTableRowCell -}; - -export default MovieStatusCell; diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.css b/frontend/src/Indexer/Index/Table/ProtocolLabel.css new file mode 100644 index 000000000..259fd5c65 --- /dev/null +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from '~Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from '~Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Indexer/Index/Table/ProtocolLabel.js b/frontend/src/Indexer/Index/Table/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Indexer/Index/Table/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Indexer/MovieBanner.js b/frontend/src/Indexer/MovieBanner.js deleted file mode 100644 index 3e24f78e7..000000000 --- a/frontend/src/Indexer/MovieBanner.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import MovieImage from './MovieImage'; - -const bannerPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXsAAABGCAIAAACiz6ObAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAAYdEVYdFNvZnR3YXJlAHBhaW50Lm5ldCA0LjEuMWMqnEsAAAVeSURBVHhe7d3dduI4EEXheaMOfzPv/2ZzpCqLsmULQWjf1P4WkwnEtrhhr7IhnX9uAHAWigPgPBQHwHkoDoDzUBwA56E4AM5DcQCch+IAOA/FwQfuuonfA6ZRHLymuDwej3+r/zp6TI9rAxqElygODtXQ7CRmwNLj+wMdioMdas3uODOPkQe7KA5Wft+aiO5gg+LAfbc1DedZiCgOzF/JTaOD+zrIjeKguF6vmnE8D9+mlKloWsIXQ2IUByU3Rqc/HomviktQneQoTnaXy/Wi/xbfnXQ03eiAfuirL+QLIyWKk1oLQWhOic5XrunoIJvc+DK+ODKiOEmpBY9HuZpbaxByUOnxX0bHLhX74Zbpxuhx3r1Ki+IkZUGJXVAS+i5YPt5io83zsOuztrY00cmJ4mSkIlgdZBWdy/Xn51kHozTMjzuxNSbmRuKvTdTnglwoTkY2ZTS66z2ogdhEx+4oJZu9Gj2uKmmDuuHKj44VirMZmix2SIXipBMHnGZ9TWdbCrPct8M43dVD/cY6QJebnWDZTIQ8KE46R6OKBhBvQ51NdqMzQ3tp1z9/ygHsES26mW4axpxsKE4uuwNO086MajU+iY7vGHIjR7kxelL+5JAAxcnlaMAx+mnrhLVDo8pb0VFoSmxCbhS50ZK8aZUMxcnFX+XH4gVgi04fHD2iH+2WqH/8fn/xFjsnVqlQnETGp1Qmjjk91URTT7vZ2dNgBtKi46lKKE4qFCeR8fWUxt5+6pWTrHqe1d+OqqNF/aBDvGOVB8VJZLI49/CmVWPXdEz5pr91Hx2UmalKKE4eFCeRlyc45hE+EGjsZMpa03/T7vaTzmTjuHicB8VJZLI42syDsShRWXhrluK0R8rdneLMNY7ipEFxEpksjngwFq0pJTXt++4mvsNidqqiOGlQnETeKE78bcxLKU4dZupXad+Y8FPfZUFxsEFxEpkvjjb2ZtRP37QRZvvNMt34XYqDVyhOIm/MOPEN8kFxFu2u77KgONigOIlMvv61lQdj8fzg3xKXzc2Gnf4NcoqDDYqTyHRxdj52XCeZ8mXANw2UEj/o0P1OcbKgOIlMvv61WV+cS/0VTZ9o9iad3Y8d8wlAbFCcRCZf/9rSg7GmqFhcNsXR7POb33LQSEVx8qA4iUwVp7uIE6ksJTdt2Cn12W+N0aIvT+W0gT09ZEBxcnn5+leVvBb1ffH6q+FdU/SA3TqlQOvtX57KUZxUKE4u49e/Xvzts3/KhurRF2Ss7LI+ydKi48xxSpUKxUln8PqPA84HuTHltKte6/H7wzFHz8WfFnKgOOkcFcfObqwRPqoMr9EMLNHx3QeLKkb1SSELipPO7vXjmBspI8r7001ULyo/x5z7wZhjTwl5UJyMNqc5ys36gnHhd6K6r7ZclL+KJ/Vh1Wr1nnrZP/z9X/1Pe/p6CwachChORspEO80Z58Y2VhqOTouMfliPU/8yZ5iV4tFKdG6rde3JIBWKk5SNOfaytyJI35pxaHYpTrE7OuT6sOWYom3qE0EuFCevelLj042SELugMHzQmmj9Z4UL+17UGnKTFsVJzRKwzc31qjnFy/ELatZzifJhZV/ClkZOFCe7koPwLrjK88vpJtKk48et0bGFfGGkRHFwiwPOF3Nj7A0pO7gWshWRFsVBoRzo69dzY1p06lJIjeLA3ef+LYsP2AUdQCgOnhSdv3FWxTtTaCgOtr7VHR3DzqeAhuJgn2Lh5XifgkVrsIvi4JCGHYVD+ZifeLQp51AYoDiYoYyU+hwpPyY0mEBxAJyH4gA4D8UBcB6KA+A8FAfAeSgOgPNQHADnoTgAzkNxAJzldvsfnbIbPuBaveQAAAAASUVORK5CYII='; - -function MovieBanner(props) { - return ( - - ); -} - -MovieBanner.propTypes = { - size: PropTypes.number.isRequired -}; - -MovieBanner.defaultProps = { - size: 70 -}; - -export default MovieBanner; diff --git a/frontend/src/Indexer/MovieCollection.css b/frontend/src/Indexer/MovieCollection.css deleted file mode 100644 index 585998fef..000000000 --- a/frontend/src/Indexer/MovieCollection.css +++ /dev/null @@ -1,9 +0,0 @@ -.monitorToggleButton { - composes: toggleButton from '~Components/MonitorToggleButton.css'; - - width: 15px; - - &:hover { - color: $iconButtonHoverLightColor; - } -} diff --git a/frontend/src/Indexer/MovieCollection.js b/frontend/src/Indexer/MovieCollection.js deleted file mode 100644 index 44136224a..000000000 --- a/frontend/src/Indexer/MovieCollection.js +++ /dev/null @@ -1,65 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; -import styles from './MovieCollection.css'; - -class MovieCollection extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasPosterError: false, - isEditImportListModalOpen: false - }; - } - - onAddImportListPress = (monitored) => { - if (this.props.collectionList) { - this.props.onMonitorTogglePress(monitored); - } else { - this.props.onMonitorTogglePress(monitored); - this.setState({ isEditImportListModalOpen: true }); - } - } - - onEditImportListModalClose = () => { - this.setState({ isEditImportListModalOpen: false }); - } - - render() { - const { - name, - collectionList, - isSaving - } = this.props; - - const monitored = collectionList !== undefined && collectionList.enabled && collectionList.enableAuto; - - return ( -
- - {name} -
- ); - } -} - -MovieCollection.propTypes = { - tmdbId: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - collectionList: PropTypes.object, - isSaving: PropTypes.bool.isRequired, - onMonitorTogglePress: PropTypes.func.isRequired -}; - -export default MovieCollection; diff --git a/frontend/src/Indexer/MovieCollectionConnector.js b/frontend/src/Indexer/MovieCollectionConnector.js index fc73d619e..09bf0827a 100644 --- a/frontend/src/Indexer/MovieCollectionConnector.js +++ b/frontend/src/Indexer/MovieCollectionConnector.js @@ -2,13 +2,13 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import createMovieCollectionListSelector from 'Store/Selectors/createMovieCollectionListSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; import MovieCollection from './MovieCollection'; function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), createMovieCollectionListSelector(), (movie, collectionList) => { const { diff --git a/frontend/src/Indexer/MovieFileStatusConnector.js b/frontend/src/Indexer/MovieFileStatusConnector.js index f015f59ec..80e831048 100644 --- a/frontend/src/Indexer/MovieFileStatusConnector.js +++ b/frontend/src/Indexer/MovieFileStatusConnector.js @@ -2,12 +2,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; +import createIndexerSelector from 'Store/Selectors/createIndexerSelector'; import MovieFileStatus from './MovieFileStatus'; function createMapStateToProps() { return createSelector( - createMovieSelector(), + createIndexerSelector(), (movie) => { return { inCinemas: movie.inCinemas, diff --git a/frontend/src/Indexer/MovieHeadshot.js b/frontend/src/Indexer/MovieHeadshot.js deleted file mode 100644 index 490d693bf..000000000 --- a/frontend/src/Indexer/MovieHeadshot.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import MovieImage from './MovieImage'; - -const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII='; - -function MovieHeadshot(props) { - return ( - - ); -} - -MovieHeadshot.propTypes = { - size: PropTypes.number.isRequired -}; - -MovieHeadshot.defaultProps = { - size: 250 -}; - -export default MovieHeadshot; diff --git a/frontend/src/Indexer/MovieImage.js b/frontend/src/Indexer/MovieImage.js deleted file mode 100644 index 36dff1c06..000000000 --- a/frontend/src/Indexer/MovieImage.js +++ /dev/null @@ -1,200 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LazyLoad from 'react-lazyload'; - -function findImage(images, coverType) { - return images.find((image) => image.coverType === coverType); -} - -function getUrl(image, coverType, size) { - if (image) { - // Remove protocol - let url = image.url.replace(/^https?:/, ''); - url = url.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`); - - return url; - } -} - -class MovieImage extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const pixelRatio = Math.ceil(window.devicePixelRatio); - - const { - images, - coverType, - size - } = props; - - const image = findImage(images, coverType); - - this.state = { - pixelRatio, - image, - url: getUrl(image, coverType, pixelRatio * size), - isLoaded: false, - hasError: false - }; - } - - componentDidMount() { - if (!this.state.url && this.props.onError) { - this.props.onError(); - } - } - - componentDidUpdate() { - const { - images, - coverType, - placeholder, - size, - onError - } = this.props; - - const { - image, - pixelRatio - } = this.state; - - const nextImage = findImage(images, coverType); - - if (nextImage && (!image || nextImage.url !== image.url)) { - this.setState({ - image: nextImage, - url: getUrl(nextImage, coverType, pixelRatio * size), - hasError: false - // Don't reset isLoaded, as we want to immediately try to - // show the new image, whether an image was shown previously - // or the placeholder was shown. - }); - } else if (!nextImage && image) { - this.setState({ - image: nextImage, - url: placeholder, - hasError: false - }); - - if (onError) { - onError(); - } - } - } - - // - // Listeners - - onError = () => { - this.setState({ - hasError: true - }); - - if (this.props.onError) { - this.props.onError(); - } - } - - onLoad = () => { - this.setState({ - isLoaded: true, - hasError: false - }); - - if (this.props.onLoad) { - this.props.onLoad(); - } - } - - // - // Render - - render() { - const { - className, - style, - placeholder, - size, - lazy, - overflow - } = this.props; - - const { - url, - hasError, - isLoaded - } = this.state; - - if (hasError || !url) { - return ( - - ); - } - - if (lazy) { - return ( - - } - > - - - ); - } - - return ( - - ); - } -} - -MovieImage.propTypes = { - className: PropTypes.string, - style: PropTypes.object, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - coverType: PropTypes.string.isRequired, - placeholder: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - lazy: PropTypes.bool.isRequired, - overflow: PropTypes.bool.isRequired, - onError: PropTypes.func, - onLoad: PropTypes.func -}; - -MovieImage.defaultProps = { - size: 250, - lazy: true, - overflow: false -}; - -export default MovieImage; diff --git a/frontend/src/Indexer/MoviePoster.js b/frontend/src/Indexer/MoviePoster.js deleted file mode 100644 index 75776a8e1..000000000 --- a/frontend/src/Indexer/MoviePoster.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import MovieImage from './MovieImage'; - -const posterPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKgAAAD3CAMAAAC+Te+kAAAAZlBMVEUvLi8vLy8vLzAvMDAwLy8wLzAwMDAwMDEwMTExMDAxMDExMTExMTIxMjIyMjIyMjMyMzMzMjMzMzMzMzQzNDQ0NDQ0NDU0NTU1NTU1NTY1NjY2NTY2NjY2Njc2Nzc3Njc3Nzc3NziHChLWAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+MKCgEdHeShUbsAAALZSURBVHja7dxNcuwgDEZR1qAVmP1vMrNUJe91GfTzCSpXo575lAymjYWGXRIDKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKFCgQIECBQoUKNA/AZ3fcTR0/owjofNDnAadnwPoPnS+xTXQeQZ0rkQ/dC4H0Gzo7ITO3bgGOnug/2PcAF3Mczt0fUj0QncG7znQBupw3PkWqh8qpkagpnyqjuArkkxaC02kRqGypCZANVYFdJZCdy9WTRVB5znQ6qTmjFFBWnOhdg20Lqnp0CpqAbRmAJRAK5JaA32zngTNvv910OSkVkJTs1oLtWugeTkNQZ/nkT2rotBHldUwNE6VQTVWGTQ6AHKggqGaBS23JkKf0hUgE1qa01Ro5fzPhoapR0HtCGg4q0poSCqFRgaAFhqxqqEr1EOgmdJaqHdaHQq1I6CunPZAHdY2aIJUBN2V9kE3H1Wd0BXrNVA7BLpgdUCtALo8pZqhdgd0Z6OyE7q1pdoH3dv7tS7o7iZ1E3R/N70Huuz795cQao65vvkqooT+vEgDdPcbj2s3zxTv9Qt/7cuhdgfUo2yAOplyqNuphfqZSqhFmEJo0HkcdPZCo0rRymRxpwSawHR+YtyBZihfvi+nQO0OqCmcYahGqYPGS4qCUJkzBpUpJdCkordyaFZxXi1UUpaZAJ2XQFOLh8ug2XXjVdD0+vYiqLIO3w1VH8EogtoxUPnpGxe04zyTA1p57i4T2nTmbnnnUuLMg1afYE2C1h+1zYEKjlknQLtPg9tb3YzU+dL054qOBb8cvcz3DlqBZhUmhdrnKo9j+pR0rkN5UHkznZHPtJIYN2TTCe1poTUyk9nWPO0bt8Ys7Ug34mlUMONtPUXMaEdXnXN1MnUzN2Z9q3Lr8XQN1DaLQJpXpiamZwltYdIUHShQoECBAgUKFChQoECBAgUKFChQoECBAgUKFChQoECBAgUKFCjQ+vgCff/mEp/vtiIAAAAASUVORK5CYII='; - -function MoviePoster(props) { - return ( - - ); -} - -MoviePoster.propTypes = { - size: PropTypes.number.isRequired -}; - -MoviePoster.defaultProps = { - size: 250 -}; - -export default MoviePoster; diff --git a/frontend/src/Indexer/NoMovie.css b/frontend/src/Indexer/NoIndexer.css similarity index 100% rename from frontend/src/Indexer/NoMovie.css rename to frontend/src/Indexer/NoIndexer.css diff --git a/frontend/src/Indexer/NoMovie.js b/frontend/src/Indexer/NoIndexer.js similarity index 56% rename from frontend/src/Indexer/NoMovie.js rename to frontend/src/Indexer/NoIndexer.js index fc9108836..f135b4d10 100644 --- a/frontend/src/Indexer/NoMovie.js +++ b/frontend/src/Indexer/NoIndexer.js @@ -3,16 +3,16 @@ import React from 'react'; import Button from 'Components/Link/Button'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; -import styles from './NoMovie.css'; +import styles from './NoIndexer.css'; -function NoMovie(props) { +function NoIndexer(props) { const { totalItems } = props; if (totalItems > 0) { return (
- {translate('AllMoviesHiddenDueToFilter')} + {translate('AllIndexersHiddenDueToFilter')}
); @@ -21,16 +21,7 @@ function NoMovie(props) { return (
- No movies found, to get started you'll want to add a new movie or import some existing ones. -
- -
- + No indexers found, to get started you'll want to add a new indexer.
@@ -38,15 +29,15 @@ function NoMovie(props) { to="/add/new" kind={kinds.PRIMARY} > - {translate('AddNewMovie')} + {translate('AddNewIndexer')}
); } -NoMovie.propTypes = { +NoIndexer.propTypes = { totalItems: PropTypes.number.isRequired }; -export default NoMovie; +export default NoIndexer; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchContent.css b/frontend/src/InteractiveSearch/InteractiveSearchContent.css deleted file mode 100644 index 5e647332f..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchContent.css +++ /dev/null @@ -1,9 +0,0 @@ -.filterMenuContainer { - display: flex; - justify-content: flex-end; - margin-bottom: 10px; -} - -.filteredMessage { - margin-top: 10px; -} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchContent.js b/frontend/src/InteractiveSearch/InteractiveSearchContent.js deleted file mode 100644 index 181677660..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchContent.js +++ /dev/null @@ -1,201 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Table from 'Components/Table/Table'; -import TableBody from 'Components/Table/TableBody'; -import { icons, sortDirections } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import InteractiveSearchRowConnector from './InteractiveSearchRowConnector'; -import styles from './InteractiveSearchContent.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: 'languageWeight', - label: translate('Language'), - isSortable: true, - isVisible: true - }, - { - name: 'qualityWeight', - label: translate('Quality'), - isSortable: true, - isVisible: true - }, - { - name: 'customFormat', - label: translate('Formats'), - 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 }), - isSortable: true, - isVisible: true - }, - { - name: 'rejections', - label: React.createElement(Icon, { name: icons.DANGER }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - }, - { - name: 'releaseWeight', - label: React.createElement(Icon, { name: icons.DOWNLOAD }), - isSortable: true, - fixedSortDirection: sortDirections.ASCENDING, - isVisible: true - } -]; - -function InteractiveSearchContent(props) { - const { - searchPayload, - isFetching, - isPopulated, - error, - totalReleasesCount, - items, - sortKey, - sortDirection, - longDateFormat, - timeFormat, - onSortPress, - onGrabPress - } = props; - - return ( -
- { - isFetching && - - } - - { - !isFetching && !!error && -
- Unable to load results for this movie search. Try again later -
- } - - { - !isFetching && isPopulated && !totalReleasesCount && -
- No results found -
- } - - { - !!totalReleasesCount && isPopulated && !items.length && -
- All results are hidden by the applied filter -
- } - - { - isPopulated && !!items.length && - - - { - items.map((item) => { - return ( - - ); - }) - } - -
- } - - { - totalReleasesCount !== items.length && !!items.length && -
- Some results are hidden by the applied filter -
- } -
- ); -} - -InteractiveSearchContent.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, - sortKey: PropTypes.string, - sortDirection: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - onSortPress: PropTypes.func.isRequired, - onGrabPress: PropTypes.func.isRequired -}; - -export default InteractiveSearchContent; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchContentConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchContentConnector.js deleted file mode 100644 index f4432d9e3..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchContentConnector.js +++ /dev/null @@ -1,96 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as releaseActions from 'Store/Actions/releaseActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import InteractiveSearchContent from './InteractiveSearchContent'; - -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)); - }, - - dispatchClearReleases(payload) { - dispatch(releaseActions.clearReleases(payload)); - }, - - onSortPress(sortKey, sortDirection) { - dispatch(releaseActions.setReleasesSort({ sortKey, sortDirection })); - }, - - onFilterSelect(selectedFilterKey) { - const action = releaseActions.setReleasesFilter; - dispatch(action({ selectedFilterKey })); - }, - - onGrabPress(payload) { - dispatch(releaseActions.grabRelease(payload)); - } - }; -} - -class InteractiveSearchContentConnector extends Component { - - // - // Lifecycle - - componentDidMount() { - const { - searchPayload, - isPopulated, - dispatchFetchReleases - } = this.props; - - // If search results are not yet isPopulated fetch them, - // otherwise re-show the existing props. - if (!isPopulated) { - dispatchFetchReleases(searchPayload); - } - } - - // - // Render - - render() { - const { - dispatchFetchReleases, - dispatchClearReleases, - ...otherProps - } = this.props; - - return ( - - - ); - } -} - -InteractiveSearchContentConnector.propTypes = { - searchPayload: PropTypes.object.isRequired, - isPopulated: PropTypes.bool.isRequired, - dispatchFetchReleases: PropTypes.func.isRequired, - dispatchClearReleases: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchContentConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterMenu.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterMenu.js deleted file mode 100644 index 10fc32c93..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchFilterMenu.js +++ /dev/null @@ -1,39 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import FilterMenu from 'Components/Menu/FilterMenu'; -import PageMenuButton from 'Components/Menu/PageMenuButton'; -import { align } from 'Helpers/Props'; -import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; -import styles from './InteractiveSearchContent.css'; - -function InteractiveSearchFilterMenu(props) { - const { - selectedFilterKey, - filters, - customFilters, - onFilterSelect - } = props; - - return ( -
- -
- ); -} - -InteractiveSearchFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -export default InteractiveSearchFilterMenu; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterMenuConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterMenuConnector.js deleted file mode 100644 index d3156aabc..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchFilterMenuConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setReleasesFilter } from 'Store/Actions/releaseActions'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import InteractiveSearchFilterMenu from './InteractiveSearchFilterMenu'; - -function createMapStateToProps(appState) { - return createSelector( - createClientSideCollectionSelector('releases'), - (releases) => { - return { - ...releases - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onFilterSelect(selectedFilterKey) { - dispatch(setReleasesFilter({ selectedFilterKey })); - } - }; -} - -class InteractiveSearchFilterMenuConnector extends Component { - - // - // Render - - render() { - const { - ...otherProps - } = this.props; - - return ( - - - ); - } -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchFilterMenuConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js deleted file mode 100644 index c42fb30d1..000000000 --- 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/InteractiveSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css deleted file mode 100644 index 3f0b46518..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.css +++ /dev/null @@ -1,74 +0,0 @@ -.cell { - composes: cell from '~Components/Table/Cells/TableRowCell.css'; -} - -.protocol { - composes: cell; - - width: 80px; -} - -.indexer { - composes: cell; - - width: 85px; -} - -.quality, -.customFormat, -.language { - composes: cell; -} - -.language { - width: 100px; -} - -.customFormatScore { - composes: cell; - - width: 55px; - font-weight: bold; - cursor: default; -} - -.rejected, -.indexerFlags, -.download { - composes: cell; - - width: 50px; -} - -.age, -.size { - composes: cell; - - white-space: nowrap; -} - -.peers { - composes: cell; - - width: 75px; -} - -.title { - composes: cell; - - max-width: 30vw; -} - -.title div { - @add-mixin truncate; -} - -.history { - composes: cell; - - width: 75px; -} - -.blacklist { - margin-left: 5px; -} diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js deleted file mode 100644 index 35f9ebb66..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchRow.js +++ /dev/null @@ -1,353 +0,0 @@ -import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ConfirmModal from 'Components/Modal/ConfirmModal'; -import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import TableRow from 'Components/Table/TableRow'; -import Popover from 'Components/Tooltip/Popover'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; -import MovieFormats from 'Indexer/MovieFormats'; -import MovieLanguage from 'Indexer/MovieLanguage'; -import MovieQuality from 'Indexer/MovieQuality'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import formatAge from 'Utilities/Number/formatAge'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import Peers from './Peers'; -import styles from './InteractiveSearchRow.css'; - -function getDownloadIcon(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return icons.SPINNER; - } else if (isGrabbed) { - return icons.DOWNLOADING; - } else if (grabError) { - return icons.DOWNLOADING; - } - - return icons.DOWNLOAD; -} - -function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { - if (isGrabbing) { - return ''; - } else if (isGrabbed) { - return 'Added to downloaded queue'; - } else if (grabError) { - return grabError; - } - - return 'Add to downloaded queue'; -} - -class InteractiveSearchRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isConfirmGrabModalOpen: false - }; - } - - // - // Listeners - - onGrabPress = () => { - const { - guid, - indexerId, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId - }); - } - - onConfirmGrabPress = () => { - this.setState({ isConfirmGrabModalOpen: true }); - } - - onGrabConfirm = () => { - this.setState({ isConfirmGrabModalOpen: false }); - - const { - guid, - indexerId, - searchPayload, - onGrabPress - } = this.props; - - onGrabPress({ - guid, - indexerId, - ...searchPayload - }); - } - - onGrabCancel = () => { - this.setState({ isConfirmGrabModalOpen: false }); - } - - // - // Render - - render() { - const { - protocol, - age, - ageHours, - ageMinutes, - publishDate, - title, - infoUrl, - indexer, - size, - seeders, - leechers, - quality, - customFormats, - customFormatScore, - languages, - indexerFlags, - rejections, - downloadAllowed, - isGrabbing, - isGrabbed, - longDateFormat, - timeFormat, - grabError, - historyGrabbedData, - historyFailedData, - blacklistData - } = this.props; - - return ( - - - - - - - {formatAge(age, ageHours, ageMinutes)} - - - - -
- {title} -
- -
- - - {indexer} - - - - { - historyGrabbedData?.date && !historyFailedData?.date && - - } - - { - historyFailedData?.date && - - } - - { - blacklistData?.date && - - } - - - - {formatBytes(size)} - - - - { - protocol === 'torrent' && - - } - - - - - - - - - - - - - - - - {customFormatScore > 0 && `+${customFormatScore}`} - {customFormatScore < 0 && customFormatScore} - - - - { - !!indexerFlags.length && - - } - title={translate('IndexerFlags')} - body={ -
    - { - indexerFlags.map((flag, index) => { - return ( -
  • - {flag} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- - - { - !!rejections.length && - - } - title={translate('ReleaseRejected')} - body={ -
    - { - rejections.map((rejection, index) => { - return ( -
  • - {rejection} -
  • - ); - }) - } -
- } - position={tooltipPositions.LEFT} - /> - } -
- - - - - - -
- ); - } -} - -InteractiveSearchRow.propTypes = { - guid: PropTypes.string.isRequired, - protocol: PropTypes.string.isRequired, - age: PropTypes.number.isRequired, - ageHours: PropTypes.number.isRequired, - ageMinutes: PropTypes.number.isRequired, - publishDate: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - infoUrl: PropTypes.string.isRequired, - indexerId: PropTypes.number.isRequired, - indexer: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - seeders: PropTypes.number, - leechers: PropTypes.number, - quality: PropTypes.object.isRequired, - customFormats: PropTypes.arrayOf(PropTypes.object).isRequired, - customFormatScore: PropTypes.number.isRequired, - languages: PropTypes.arrayOf(PropTypes.object).isRequired, - rejections: PropTypes.arrayOf(PropTypes.string).isRequired, - indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, - downloadAllowed: PropTypes.bool.isRequired, - isGrabbing: PropTypes.bool.isRequired, - isGrabbed: PropTypes.bool.isRequired, - grabError: PropTypes.string, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - searchPayload: PropTypes.object.isRequired, - onGrabPress: PropTypes.func.isRequired, - historyFailedData: PropTypes.object, - historyGrabbedData: PropTypes.object, - blacklistData: PropTypes.object -}; - -InteractiveSearchRow.defaultProps = { - rejections: [], - isGrabbing: false, - isGrabbed: false -}; - -export default InteractiveSearchRow; diff --git a/frontend/src/InteractiveSearch/InteractiveSearchRowConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchRowConnector.js deleted file mode 100644 index a8d6c6702..000000000 --- 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.movieBlacklist.items, - (guid, movieHistory, movieBlacklist) => { - - let blacklistData = {}; - 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); - blacklistData = movieBlacklist.find((item) => item.sourceTitle === historyGrabbedData.sourceTitle); - } - - return { - historyGrabbedData, - historyFailedData, - blacklistData - }; - } - ); -} - -class InteractiveSearchRowConnector extends Component { - - // - // Render - - render() { - const { - historyGrabbedData, - historyFailedData, - blacklistData, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -InteractiveSearchRowConnector.propTypes = { - historyGrabbedData: PropTypes.object, - historyFailedData: PropTypes.object, - blacklistData: PropTypes.object -}; - -export default connect(createMapStateToProps)(InteractiveSearchRowConnector); diff --git a/frontend/src/InteractiveSearch/InteractiveSearchTable.js b/frontend/src/InteractiveSearch/InteractiveSearchTable.js deleted file mode 100644 index af6585435..000000000 --- a/frontend/src/InteractiveSearch/InteractiveSearchTable.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import InteractiveSearchContentConnector from './InteractiveSearchContentConnector'; - -function InteractiveSearchTable(props) { - - return ( - - ); -} - -InteractiveSearchTable.propTypes = { -}; - -export default InteractiveSearchTable; diff --git a/frontend/src/Search/IndexerIndex.css b/frontend/src/Search/IndexerIndex.css new file mode 100644 index 000000000..9adeaea43 --- /dev/null +++ b/frontend/src/Search/IndexerIndex.css @@ -0,0 +1,57 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.errorMessage { + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.contentBody { + composes: contentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Search/Menus/MovieIndexFilterMenu.js b/frontend/src/Search/Menus/MovieIndexFilterMenu.js new file mode 100644 index 000000000..a2846512c --- /dev/null +++ b/frontend/src/Search/Menus/MovieIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import { align } from 'Helpers/Props'; +import MovieIndexFilterModalConnector from 'Indexer/Index/MovieIndexFilterModalConnector'; + +function MovieIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +MovieIndexFilterMenu.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDisabled: PropTypes.bool.isRequired, + onFilterSelect: PropTypes.func.isRequired +}; + +MovieIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default MovieIndexFilterMenu; diff --git a/frontend/src/Search/Menus/MovieIndexSearchMenu.js b/frontend/src/Search/Menus/MovieIndexSearchMenu.js new file mode 100644 index 000000000..91c147a47 --- /dev/null +++ b/frontend/src/Search/Menus/MovieIndexSearchMenu.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import SearchMenuItem from 'Components/Menu/SearchMenuItem'; +import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; +import { align, icons } from 'Helpers/Props'; + +class MovieIndexSearchMenu extends Component { + + render() { + const { + isDisabled, + onSearchPress + } = this.props; + + return ( + + + + + Search Missing + + + + Search Cutoff Unmet + + + + ); + } +} + +MovieIndexSearchMenu.propTypes = { + isDisabled: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +export default MovieIndexSearchMenu; diff --git a/frontend/src/Search/Menus/MovieIndexSortMenu.js b/frontend/src/Search/Menus/MovieIndexSortMenu.js new file mode 100644 index 000000000..fe006f414 --- /dev/null +++ b/frontend/src/Search/Menus/MovieIndexSortMenu.js @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenu from 'Components/Menu/SortMenu'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; +import { align, sortDirections } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +function MovieIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + {translate('Title')} + + + + {translate('Studio')} + + + + {translate('QualityProfile')} + + + + {translate('Added')} + + + + {translate('Year')} + + + + {translate('InCinemas')} + + + + {translate('PhysicalRelease')} + + + + {translate('DigitalRelease')} + + + + {translate('Path')} + + + + {translate('SizeOnDisk')} + + + + {translate('Certification')} + + + + ); +} + +MovieIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default MovieIndexSortMenu; diff --git a/frontend/src/Search/MovieEditorFooter.css b/frontend/src/Search/MovieEditorFooter.css new file mode 100644 index 000000000..59049c184 --- /dev/null +++ b/frontend/src/Search/MovieEditorFooter.css @@ -0,0 +1,57 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedMovieLabel { + text-align: left; + } +} diff --git a/frontend/src/Search/MovieEditorFooter.js b/frontend/src/Search/MovieEditorFooter.js new file mode 100644 index 000000000..2fc16782b --- /dev/null +++ b/frontend/src/Search/MovieEditorFooter.js @@ -0,0 +1,96 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextInput from 'Components/Form/TextInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import styles from './MovieEditorFooter.css'; + +class MovieEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + searchingReleases: false, + searchQuery: '' + }; + } + + componentDidUpdate(prevProps) { + const { + isFetching, + searchError + } = this.props; + + if (prevProps.isFetching && !isFetching && !searchError) { + this.setState({ + searchingReleases: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onSearchPress = () => { + this.props.onSearchPress(this.state.searchQuery); + } + + // + // Render + + render() { + const { + isFetching + } = this.props; + + const { + searchQuery + } = this.state; + + return ( + +
+ +
+ +
+
+
+ + + Search + +
+
+
+
+ ); + } +} + +MovieEditorFooter.propTypes = { + isFetching: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired, + searchError: PropTypes.object +}; + +export default MovieEditorFooter; diff --git a/frontend/src/Search/MovieEditorFooterLabel.css b/frontend/src/Search/MovieEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Search/MovieEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Search/MovieEditorFooterLabel.js b/frontend/src/Search/MovieEditorFooterLabel.js new file mode 100644 index 000000000..805ecd39e --- /dev/null +++ b/frontend/src/Search/MovieEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import { icons } from 'Helpers/Props'; +import styles from './MovieEditorFooterLabel.css'; + +function MovieEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +MovieEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +MovieEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default MovieEditorFooterLabel; diff --git a/frontend/src/Search/SearchConnector.js b/frontend/src/Search/SearchConnector.js new file mode 100644 index 000000000..39c91797f --- /dev/null +++ b/frontend/src/Search/SearchConnector.js @@ -0,0 +1,76 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import withScrollPosition from 'Components/withScrollPosition'; +import { fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions'; +import scrollPositions from 'Store/scrollPositions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector'; +import SearchIndex from './SearchIndex'; + +function createMapStateToProps() { + return createSelector( + createReleaseClientSideCollectionItemsSelector('releases'), + createDimensionsSelector(), + ( + movies, + dimensionsState + ) => { + return { + ...movies, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setReleasesTableOption(payload)); + }, + + onSortSelect(sortKey) { + dispatch(setReleasesSort({ sortKey })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setReleasesFilter({ selectedFilterKey })); + }, + + onSearchPress(payload) { + dispatch(fetchReleases(payload)); + } + }; +} + +class SearchConnector extends Component { + + onScroll = ({ scrollTop }) => { + scrollPositions.movieIndex = scrollTop; + } + + // + // Render + + render() { + return ( + + ); + } +} + +SearchConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + onSearchPress: PropTypes.func.isRequired, + items: PropTypes.arrayOf(PropTypes.object) +}; + +export default withScrollPosition( + connect(createMapStateToProps, createMapDispatchToProps)(SearchConnector), + 'releases' +); diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js new file mode 100644 index 000000000..b588e6296 --- /dev/null +++ b/frontend/src/Search/SearchIndex.js @@ -0,0 +1,291 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import { align, icons, sortDirections } from 'Helpers/Props'; +import NoIndexer from 'Indexer/NoIndexer'; +import * as keyCodes from 'Utilities/Constants/keyCodes'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import translate from 'Utilities/String/translate'; +import MovieIndexFilterMenu from './Menus/MovieIndexFilterMenu'; +import MovieIndexSortMenu from './Menus/MovieIndexSortMenu'; +import MovieEditorFooter from './MovieEditorFooter.js'; +import MovieIndexTableConnector from './Table/MovieIndexTableConnector'; +import styles from './IndexerIndex.css'; + +function getViewComponent() { + return MovieIndexTableConnector; +} + +class SearchIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scroller: null, + jumpBarItems: { order: [] }, + jumpToCharacter: null, + searchType: null, + lastToggled: null + }; + } + + componentDidMount() { + this.setJumpBarItems(); + + window.addEventListener('keyup', this.onKeyUp); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection + } = this.props; + + if (sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection || + hasDifferentItemsOrOrder(prevProps.items, items) + ) { + this.setJumpBarItems(); + } + + if (this.state.jumpToCharacter != null) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setScrollerRef = (ref) => { + this.setState({ scroller: ref }); + } + + setJumpBarItems() { + const { + items, + sortKey, + sortDirection + } = this.props; + + // Reset if not sorting by sortTitle + if (sortKey !== 'sortTitle') { + this.setState({ jumpBarItems: { order: [] } }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + let char = item.sortTitle.charAt(0); + + if (!isNaN(char)) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === sortDirections.DESCENDING) { + order.reverse(); + } + + const jumpBarItems = { + characters, + order + }; + + this.setState({ jumpBarItems }); + } + + // + // Listeners + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onSearchPress = (query) => { + console.log('index', query); + this.props.onSearchPress({ query }); + } + + onKeyUp = (event) => { + const jumpBarItems = this.state.jumpBarItems.order; + if (event.path.length === 4) { + if (event.keyCode === keyCodes.HOME && event.ctrlKey) { + this.setState({ jumpToCharacter: jumpBarItems[0] }); + } + if (event.keyCode === keyCodes.END && event.ctrlKey) { + this.setState({ jumpToCharacter: jumpBarItems[jumpBarItems.length - 1] }); + } + } + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + onScroll, + onSortSelect, + onFilterSelect, + ...otherProps + } = this.props; + + const { + scroller, + jumpBarItems, + jumpToCharacter + } = this.state; + + const ViewComponent = getViewComponent(); + const isLoaded = !!(!error && isPopulated && items.length && scroller); + const hasNoIndexer = !totalItems; + + return ( + + + + + + + + + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
+ {getErrorMessage(error, 'Failed to load movie from API')} +
+ } + + { + isLoaded && +
+ +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.order.length && + + } +
+ + +
+ ); + } +} + +SearchIndex.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: 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.oneOf(sortDirections.all), + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default SearchIndex; diff --git a/frontend/src/Search/Table/MovieIndexActionsCell.js b/frontend/src/Search/Table/MovieIndexActionsCell.js new file mode 100644 index 000000000..554ec54d3 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexActionsCell.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import { icons } from 'Helpers/Props'; +import DeleteMovieModal from 'Indexer/Delete/DeleteMovieModal'; +import EditMovieModalConnector from 'Indexer/Edit/EditMovieModalConnector'; +import translate from 'Utilities/String/translate'; + +class MovieIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onDeleteMoviePress = () => { + this.setState({ + isEditMovieModalOpen: false, + isDeleteMovieModalOpen: true + }); + } + + onDeleteMovieModalClose = () => { + this.setState({ isDeleteMovieModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingMovie, + onRefreshMoviePress, + ...otherProps + } = this.props; + + const { + isEditMovieModalOpen, + isDeleteMovieModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +MovieIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingMovie: PropTypes.bool.isRequired, + onRefreshMoviePress: PropTypes.func.isRequired +}; + +export default MovieIndexActionsCell; diff --git a/frontend/src/Search/Table/MovieIndexHeader.css b/frontend/src/Search/Table/MovieIndexHeader.css new file mode 100644 index 000000000..7322d6ebe --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexHeader.css @@ -0,0 +1,37 @@ +.status { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 60px; +} + +.title { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.indexer { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 85px; +} + +.age, +.size, +.peers { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 75px; +} + +.indexerFlags { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 50px; +} + +.actions { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Search/Table/MovieIndexHeader.js b/frontend/src/Search/Table/MovieIndexHeader.js new file mode 100644 index 000000000..edd07e056 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexHeader.js @@ -0,0 +1,106 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import { icons } from 'Helpers/Props'; +import styles from './MovieIndexHeader.css'; + +class MovieIndexHeader extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isTableOptionsModalOpen: false + }; + } + + // + // Listeners + + onTableOptionsPress = () => { + this.setState({ isTableOptionsModalOpen: true }); + } + + onTableOptionsModalClose = () => { + this.setState({ isTableOptionsModalOpen: false }); + } + + // + // Render + + render() { + const { + columns, + onTableOptionChange, + ...otherProps + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + ); + } + + return ( + + {label} + + ); + }) + } + + + + ); + } +} + +MovieIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default MovieIndexHeader; diff --git a/frontend/src/Search/Table/MovieIndexHeaderConnector.js b/frontend/src/Search/Table/MovieIndexHeaderConnector.js new file mode 100644 index 000000000..7361b0fef --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setMovieTableOption } from 'Store/Actions/indexerIndexActions'; +import MovieIndexHeader from './MovieIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setMovieTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(MovieIndexHeader); diff --git a/frontend/src/Search/Table/MovieIndexItemConnector.js b/frontend/src/Search/Table/MovieIndexItemConnector.js new file mode 100644 index 000000000..04db22740 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexItemConnector.js @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { executeCommand } from 'Store/Actions/commandActions'; + +function createReleaseSelector() { + return createSelector( + (state, { guid }) => guid, + (state) => state.releases.items, + (guid, releases) => { + return releases.find((t) => t.guid === guid); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createReleaseSelector(), + ( + movie + ) => { + + // If a movie is deleted this selector may fire before the parent + // selecors, which will result in an undefined movie, if that happens + // we want to return early here and again in the render function to avoid + // trying to show a movie that has no information available. + + if (!movie) { + return {}; + } + + return { + ...movie + }; + } + ); +} + +const mapDispatchToProps = { + dispatchExecuteCommand: executeCommand +}; + +class MovieIndexItemConnector extends Component { + + // + // Render + + render() { + const { + guid, + component: ItemComponent, + ...otherProps + } = this.props; + + if (!guid) { + return null; + } + + return ( + + ); + } +} + +MovieIndexItemConnector.propTypes = { + guid: PropTypes.string, + component: PropTypes.elementType.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieIndexItemConnector); diff --git a/frontend/src/Search/Table/MovieIndexRow.css b/frontend/src/Search/Table/MovieIndexRow.css new file mode 100644 index 000000000..68c921b1d --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexRow.css @@ -0,0 +1,57 @@ +.cell { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.status { + composes: cell; + + flex: 0 0 60px; +} + +.title { + composes: cell; + + flex: 4 0 110px; +} + +.indexer { + composes: cell; + + flex: 0 0 85px; +} + +.age, +.size, +.peers { + composes: cell; + + flex: 0 0 75px; +} + +.indexerFlags { + composes: cell; + + flex: 0 0 50px; +} + +.actions { + composes: cell; + + flex: 0 1 90px; + min-width: 90px; +} + +.checkInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.externalLinks { + margin: 0 2px; + width: 22px; + text-align: center; +} diff --git a/frontend/src/Search/Table/MovieIndexRow.js b/frontend/src/Search/Table/MovieIndexRow.js new file mode 100644 index 000000000..a1eaba3cb --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexRow.js @@ -0,0 +1,213 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import Popover from 'Components/Tooltip/Popover'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import Peers from './Peers'; +import ProtocolLabel from './ProtocolLabel'; +import styles from './MovieIndexRow.css'; + +class MovieIndexRow extends Component { + + // + // Render + + render() { + const { + protocol, + age, + ageHours, + ageMinutes, + publishDate, + title, + infoUrl, + indexer, + size, + seeders, + leechers, + indexerFlags, + columns, + longDateFormat, + timeFormat + } = this.props; + + return ( + <> + { + columns.map((column) => { + const { + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (column.name === 'protocol') { + return ( + + + + ); + } + + if (column.name === 'age') { + return ( + + {formatAge(age, ageHours, ageMinutes)} + + ); + } + + if (column.name === 'title') { + return ( + + +
+ {title} +
+ +
+ ); + } + + if (column.name === 'indexer') { + return ( + + {indexer} + + ); + } + + if (column.name === 'size') { + return ( + + {formatBytes(size)} + + ); + } + + if (column.name === 'peers') { + return ( + + { + protocol === 'torrent' && + + } + + ); + } + + if (column.name === 'indexerFlags') { + return ( + + { + !!indexerFlags.length && + + } + title={translate('IndexerFlags')} + body={ +
    + { + indexerFlags.map((flag, index) => { + return ( +
  • + {flag} +
  • + ); + }) + } +
+ } + position={tooltipPositions.LEFT} + /> + } +
+ ); + } + + if (column.name === 'actions') { + return ( + + + + ); + } + + return null; + }) + } + + ); + } +} + +MovieIndexRow.propTypes = { + guid: PropTypes.string.isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + indexer: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + seeders: PropTypes.number, + leechers: PropTypes.number, + indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default MovieIndexRow; diff --git a/frontend/src/Search/Table/MovieIndexTable.css b/frontend/src/Search/Table/MovieIndexTable.css new file mode 100644 index 000000000..23ab127b5 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from '~Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Search/Table/MovieIndexTable.js b/frontend/src/Search/Table/MovieIndexTable.js new file mode 100644 index 000000000..f076a7170 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexTable.js @@ -0,0 +1,124 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import VirtualTable from 'Components/Table/VirtualTable'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import { sortDirections } from 'Helpers/Props'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import MovieIndexHeaderConnector from './MovieIndexHeaderConnector'; +import MovieIndexItemConnector from './MovieIndexItemConnector'; +import MovieIndexRow from './MovieIndexRow'; +import styles from './MovieIndexTable.css'; + +class MovieIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const { + items, + jumpToCharacter + } = this.props; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + + const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (scrollIndex != null) { + this.setState({ scrollIndex }); + } + } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { + this.setState({ scrollIndex: null }); + } + } + + // + // Control + + rowRenderer = ({ key, rowIndex, style }) => { + const { + items, + columns, + longDateFormat, + timeFormat + } = this.props; + + const release = items[rowIndex]; + + return ( + + + + ); + } + + // + // Render + + render() { + const { + items, + columns, + sortKey, + sortDirection, + isSmallScreen, + onSortPress, + scroller + } = this.props; + + return ( + + } + columns={columns} + /> + ); + } +} + +MovieIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + jumpToCharacter: PropTypes.string, + isSmallScreen: PropTypes.bool.isRequired, + scroller: PropTypes.instanceOf(Element).isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + onSortPress: PropTypes.func.isRequired +}; + +export default MovieIndexTable; diff --git a/frontend/src/Search/Table/MovieIndexTableConnector.js b/frontend/src/Search/Table/MovieIndexTableConnector.js new file mode 100644 index 000000000..65bec32f8 --- /dev/null +++ b/frontend/src/Search/Table/MovieIndexTableConnector.js @@ -0,0 +1,31 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setMovieSort } from 'Store/Actions/indexerIndexActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import MovieIndexTable from './MovieIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + (state) => state.releases.columns, + createUISettingsSelector(), + (dimensions, columns, uiSettings) => { + return { + isSmallScreen: dimensions.isSmallScreen, + columns, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setMovieSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(MovieIndexTable); diff --git a/frontend/src/InteractiveSearch/Peers.js b/frontend/src/Search/Table/Peers.js similarity index 100% rename from frontend/src/InteractiveSearch/Peers.js rename to frontend/src/Search/Table/Peers.js diff --git a/frontend/src/Search/Table/ProtocolLabel.css b/frontend/src/Search/Table/ProtocolLabel.css new file mode 100644 index 000000000..259fd5c65 --- /dev/null +++ b/frontend/src/Search/Table/ProtocolLabel.css @@ -0,0 +1,13 @@ +.torrent { + composes: label from '~Components/Label.css'; + + border-color: $torrentColor; + background-color: $torrentColor; +} + +.usenet { + composes: label from '~Components/Label.css'; + + border-color: $usenetColor; + background-color: $usenetColor; +} diff --git a/frontend/src/Search/Table/ProtocolLabel.js b/frontend/src/Search/Table/ProtocolLabel.js new file mode 100644 index 000000000..e8a08943c --- /dev/null +++ b/frontend/src/Search/Table/ProtocolLabel.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Label from 'Components/Label'; +import styles from './ProtocolLabel.css'; + +function ProtocolLabel({ protocol }) { + const protocolName = protocol === 'usenet' ? 'nzb' : protocol; + + return ( + + ); +} + +ProtocolLabel.propTypes = { + protocol: PropTypes.string.isRequired +}; + +export default ProtocolLabel; diff --git a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js index 1eaf098d7..9767b7b04 100644 --- a/frontend/src/Settings/Indexers/IndexerSettingsConnector.js +++ b/frontend/src/Settings/Indexers/IndexerSettingsConnector.js @@ -1,11 +1,11 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { testAllIndexers } from 'Store/Actions/settingsActions'; +import { testAllIndexers } from 'Store/Actions/indexerActions'; import IndexerSettings from './IndexerSettings'; function createMapStateToProps() { return createSelector( - (state) => state.settings.indexers.isTestingAll, + (state) => state.indexers.isTestingAll, (isTestingAll) => { return { isTestingAll diff --git a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js index d79f028da..a6953d72b 100644 --- a/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js +++ b/frontend/src/Settings/Indexers/Indexers/AddIndexerModalContentConnector.js @@ -3,12 +3,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/settingsActions'; +import { fetchIndexerSchema, selectIndexerSchema } from 'Store/Actions/indexerActions'; import AddIndexerModalContent from './AddIndexerModalContent'; function createMapStateToProps() { return createSelector( - (state) => state.settings.indexers, + (state) => state.indexers, (indexers) => { const { isSchemaFetching, diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js index e1d524f31..27fc2d0fa 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalConnector.js @@ -2,11 +2,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { clearPendingChanges } from 'Store/Actions/baseActions'; -import { cancelSaveIndexer, cancelTestIndexer } from 'Store/Actions/settingsActions'; +import { cancelSaveIndexer, cancelTestIndexer } from 'Store/Actions/indexerActions'; import EditIndexerModal from './EditIndexerModal'; function createMapDispatchToProps(dispatch, props) { - const section = 'settings.indexers'; + const section = 'indexers'; return { dispatchClearPendingChanges() { diff --git a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js index 38e091d19..2d6fe7d59 100644 --- a/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js +++ b/frontend/src/Settings/Indexers/Indexers/EditIndexerModalContentConnector.js @@ -2,14 +2,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/settingsActions'; -import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { saveIndexer, setIndexerFieldValue, setIndexerValue, testIndexer } from 'Store/Actions/indexerActions'; +import createProviderSelector from 'Store/Selectors/createProviderSelector'; import EditIndexerModalContent from './EditIndexerModalContent'; function createMapStateToProps() { return createSelector( (state) => state.settings.advancedSettings, - createProviderSettingsSelector('indexers'), + createProviderSelector('indexers'), (advancedSettings, indexer) => { return { advancedSettings, diff --git a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js index 9acb86f22..b857ddc26 100644 --- a/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js +++ b/frontend/src/Settings/Indexers/Indexers/IndexersConnector.js @@ -2,14 +2,14 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { cloneIndexer, deleteIndexer, fetchIndexers } from 'Store/Actions/settingsActions'; +import { cloneIndexer, deleteIndexer, fetchIndexers } from 'Store/Actions/indexerActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import sortByName from 'Utilities/Array/sortByName'; import Indexers from './Indexers'; function createMapStateToProps() { return createSelector( - createSortedSectionSelector('settings.indexers', sortByName), + createSortedSectionSelector('indexers', sortByName), (indexers) => indexers ); } diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js deleted file mode 100644 index e2e85cbb5..000000000 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ /dev/null @@ -1,147 +0,0 @@ -import { createAction } from 'redux-actions'; -import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; -import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; -import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; -import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; -import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; -import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; -import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; -import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; -import { createThunk } from 'Store/thunks'; -import getSectionState from 'Utilities/State/getSectionState'; -import selectProviderSchema from 'Utilities/State/selectProviderSchema'; -import updateSectionState from 'Utilities/State/updateSectionState'; - -// -// Variables - -const section = 'settings.indexers'; - -// -// Actions Types - -export const FETCH_INDEXERS = 'settings/indexers/fetchIndexers'; -export const FETCH_INDEXER_SCHEMA = 'settings/indexers/fetchIndexerSchema'; -export const SELECT_INDEXER_SCHEMA = 'settings/indexers/selectIndexerSchema'; -export const CLONE_INDEXER = 'settings/indexers/cloneIndexer'; -export const SET_INDEXER_VALUE = 'settings/indexers/setIndexerValue'; -export const SET_INDEXER_FIELD_VALUE = 'settings/indexers/setIndexerFieldValue'; -export const SAVE_INDEXER = 'settings/indexers/saveIndexer'; -export const CANCEL_SAVE_INDEXER = 'settings/indexers/cancelSaveIndexer'; -export const DELETE_INDEXER = 'settings/indexers/deleteIndexer'; -export const TEST_INDEXER = 'settings/indexers/testIndexer'; -export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; -export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; - -// -// Action Creators - -export const fetchIndexers = createThunk(FETCH_INDEXERS); -export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); -export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); -export const cloneIndexer = createAction(CLONE_INDEXER); - -export const saveIndexer = createThunk(SAVE_INDEXER); -export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); -export const deleteIndexer = createThunk(DELETE_INDEXER); -export const testIndexer = createThunk(TEST_INDEXER); -export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); -export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); - -export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { - return { - section, - ...payload - }; -}); - -// -// Details - -export default { - - // - // State - - defaultState: { - isFetching: false, - isPopulated: false, - error: null, - isSchemaFetching: false, - isSchemaPopulated: false, - schemaError: null, - schema: [], - selectedSchema: {}, - isSaving: false, - saveError: null, - isTesting: false, - isTestingAll: false, - items: [], - pendingChanges: {} - }, - - // - // Action Handlers - - actionHandlers: { - [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), - [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), - - [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), - [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), - [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), - [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), - [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), - [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') - }, - - // - // Reducers - - reducers: { - [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), - [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), - - [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { - return selectProviderSchema(state, section, payload, (selectedSchema) => { - selectedSchema.enableRss = selectedSchema.supportsRss; - selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; - selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; - - return selectedSchema; - }); - }, - - [CLONE_INDEXER]: function(state, { payload }) { - const id = payload.id; - const newState = getSectionState(state, section); - const item = newState.items.find((i) => i.id === id); - - // Use selectedSchema so `createProviderSettingsSelector` works properly - const selectedSchema = { ...item }; - delete selectedSchema.id; - delete selectedSchema.name; - - selectedSchema.fields = selectedSchema.fields.map((field) => { - return { ...field }; - }); - - newState.selectedSchema = selectedSchema; - - // Set the name in pendingChanges - newState.pendingChanges = { - name: `${item.name} - Copy` - }; - - return updateSectionState(state, section, newState); - } - } - -}; diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 53281e985..8be8e7c67 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -3,8 +3,9 @@ import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; import * as history from './historyActions'; +import * as indexers from './indexerActions'; +import * as indexerIndex from './indexerIndexActions'; import * as movies from './movieActions'; -import * as movieIndex from './movieIndexActions'; import * as oAuth from './oAuthActions'; import * as paths from './pathActions'; import * as providerOptions from './providerOptionActions'; @@ -24,7 +25,8 @@ export default [ providerOptions, releases, movies, - movieIndex, + indexers, + indexerIndex, settings, system, tags diff --git a/frontend/src/Store/Actions/indexerActions.js b/frontend/src/Store/Actions/indexerActions.js new file mode 100644 index 000000000..86a720c90 --- /dev/null +++ b/frontend/src/Store/Actions/indexerActions.js @@ -0,0 +1,141 @@ +import { createAction } from 'redux-actions'; +import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; +import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; +import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; +import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler'; +import createTestAllProvidersHandler from 'Store/Actions/Creators/createTestAllProvidersHandler'; +import createTestProviderHandler, { createCancelTestProviderHandler } from 'Store/Actions/Creators/createTestProviderHandler'; +import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer'; +import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; +import { createThunk, handleThunks } from 'Store/thunks'; +import getSectionState from 'Utilities/State/getSectionState'; +import selectProviderSchema from 'Utilities/State/selectProviderSchema'; +import updateSectionState from 'Utilities/State/updateSectionState'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'indexers'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSchemaFetching: false, + isSchemaPopulated: false, + schemaError: null, + schema: [], + selectedSchema: {}, + isSaving: false, + saveError: null, + isTesting: false, + isTestingAll: false, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_INDEXERS = 'indexers/fetchIndexers'; +export const FETCH_INDEXER_SCHEMA = 'indexers/fetchIndexerSchema'; +export const SELECT_INDEXER_SCHEMA = 'indexers/selectIndexerSchema'; +export const CLONE_INDEXER = 'indexers/cloneIndexer'; +export const SET_INDEXER_VALUE = 'indexers/setIndexerValue'; +export const SET_INDEXER_FIELD_VALUE = 'indexers/setIndexerFieldValue'; +export const SAVE_INDEXER = 'indexers/saveIndexer'; +export const CANCEL_SAVE_INDEXER = 'indexers/cancelSaveIndexer'; +export const DELETE_INDEXER = 'indexers/deleteIndexer'; +export const TEST_INDEXER = 'indexers/testIndexer'; +export const CANCEL_TEST_INDEXER = 'indexers/cancelTestIndexer'; +export const TEST_ALL_INDEXERS = 'indexers/testAllIndexers'; + +// +// Action Creators + +export const fetchIndexers = createThunk(FETCH_INDEXERS); +export const fetchIndexerSchema = createThunk(FETCH_INDEXER_SCHEMA); +export const selectIndexerSchema = createAction(SELECT_INDEXER_SCHEMA); +export const cloneIndexer = createAction(CLONE_INDEXER); + +export const saveIndexer = createThunk(SAVE_INDEXER); +export const cancelSaveIndexer = createThunk(CANCEL_SAVE_INDEXER); +export const deleteIndexer = createThunk(DELETE_INDEXER); +export const testIndexer = createThunk(TEST_INDEXER); +export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); +export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); + +export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +export const setIndexerFieldValue = createAction(SET_INDEXER_FIELD_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_INDEXERS]: createFetchHandler(section, '/indexer'), + [FETCH_INDEXER_SCHEMA]: createFetchSchemaHandler(section, '/indexer/schema'), + + [SAVE_INDEXER]: createSaveProviderHandler(section, '/indexer'), + [CANCEL_SAVE_INDEXER]: createCancelSaveProviderHandler(section), + [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), + [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), + [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + [SET_INDEXER_VALUE]: createSetSettingValueReducer(section), + [SET_INDEXER_FIELD_VALUE]: createSetProviderFieldValueReducer(section), + + [SELECT_INDEXER_SCHEMA]: (state, { payload }) => { + return selectProviderSchema(state, section, payload, (selectedSchema) => { + selectedSchema.enableRss = selectedSchema.supportsRss; + selectedSchema.enableAutomaticSearch = selectedSchema.supportsSearch; + selectedSchema.enableInteractiveSearch = selectedSchema.supportsSearch; + + return selectedSchema; + }); + }, + + [CLONE_INDEXER]: function(state, { payload }) { + const id = payload.id; + const newState = getSectionState(state, section); + const item = newState.items.find((i) => i.id === id); + + // Use selectedSchema so `createProviderSettingsSelector` works properly + const selectedSchema = { ...item }; + delete selectedSchema.id; + delete selectedSchema.name; + + selectedSchema.fields = selectedSchema.fields.map((field) => { + return { ...field }; + }); + + newState.selectedSchema = selectedSchema; + + // Set the name in pendingChanges + newState.pendingChanges = { + name: `${item.name} - Copy` + }; + + return updateSectionState(state, section, newState); + } +}, defaultState, section); diff --git a/frontend/src/Store/Actions/indexerIndexActions.js b/frontend/src/Store/Actions/indexerIndexActions.js new file mode 100644 index 000000000..291970dec --- /dev/null +++ b/frontend/src/Store/Actions/indexerIndexActions.js @@ -0,0 +1,272 @@ +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import translate from 'Utilities/String/translate'; +import { set, updateItem } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import { filterPredicates, filters, sortPredicates } from './movieActions'; + +// +// Variables + +export const section = 'indexerIndex'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + + tableOptions: { + showSearchAction: false + }, + + columns: [ + { + name: 'select', + columnLabel: 'Select', + isSortable: false, + isVisible: true, + isModifiable: false, + isHidden: true + }, + { + name: 'status', + columnLabel: translate('ReleaseStatus'), + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'name', + label: 'Indexer Name', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'protocol', + label: translate('Protocol'), + isSortable: true, + isVisible: false + }, + { + name: 'privacy', + label: translate('Privacy'), + isSortable: true, + isVisible: false + }, + { + name: 'added', + label: translate('Added'), + isSortable: true, + isVisible: false + }, + { + name: 'capabilities', + label: 'Capabilities', + isSortable: false, + isVisible: false + }, + { + name: 'tags', + label: translate('Tags'), + isSortable: false, + isVisible: false + }, + { + name: 'actions', + columnLabel: translate('Actions'), + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + studio: function(item) { + const studio = item.studio; + + return studio ? studio.toLowerCase() : ''; + }, + + collection: function(item) { + const { collection ={} } = item; + + return collection.name; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + filterPredicates, + + filterBuilderProps: [ + { + name: 'monitored', + label: translate('Monitored'), + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'title', + label: 'Indexer Name', + type: filterBuilderTypes.STRING + }, + { + name: 'added', + label: translate('Added'), + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'tags', + label: translate('Tags'), + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'indexerIndex.sortKey', + 'indexerIndex.sortDirection', + 'indexerIndex.selectedFilterKey', + 'indexerIndex.customFilters', + 'indexerIndex.view', + 'indexerIndex.columns', + 'indexerIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_MOVIE_SORT = 'indexerIndex/setMovieSort'; +export const SET_MOVIE_FILTER = 'indexerIndex/setMovieFilter'; +export const SET_MOVIE_VIEW = 'indexerIndex/setMovieView'; +export const SET_MOVIE_TABLE_OPTION = 'indexerIndex/setMovieTableOption'; +export const SAVE_MOVIE_EDITOR = 'indexerIndex/saveMovieEditor'; +export const BULK_DELETE_MOVIE = 'indexerIndex/bulkDeleteMovie'; + +// +// Action Creators + +export const setMovieSort = createAction(SET_MOVIE_SORT); +export const setMovieFilter = createAction(SET_MOVIE_FILTER); +export const setMovieView = createAction(SET_MOVIE_VIEW); +export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION); +export const saveMovieEditor = createThunk(SAVE_MOVIE_EDITOR); +export const bulkDeleteMovie = createThunk(BULK_DELETE_MOVIE); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_MOVIE_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/movie/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((movie) => { + return updateItem({ + id: movie.id, + section: 'movies', + ...movie + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = createAjaxRequest({ + url: '/movie/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignaR will take care of removing the movie from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MOVIE_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_MOVIE_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_MOVIE_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_MOVIE_TABLE_OPTION]: createSetTableOptionReducer(section) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/movieIndexActions.js b/frontend/src/Store/Actions/movieIndexActions.js deleted file mode 100644 index 371f5b200..000000000 --- a/frontend/src/Store/Actions/movieIndexActions.js +++ /dev/null @@ -1,507 +0,0 @@ -import { createAction } from 'redux-actions'; -import { batchActions } from 'redux-batched-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; -import { createThunk, handleThunks } from 'Store/thunks'; -import sortByName from 'Utilities/Array/sortByName'; -import createAjaxRequest from 'Utilities/createAjaxRequest'; -import translate from 'Utilities/String/translate'; -import { set, updateItem } from './baseActions'; -import createHandleActions from './Creators/createHandleActions'; -import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; -import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; -import { filterPredicates, filters, sortPredicates } from './movieActions'; - -// -// Variables - -export const section = 'movieIndex'; - -// -// State - -export const defaultState = { - isSaving: false, - saveError: null, - isDeleting: false, - deleteError: null, - sortKey: 'sortTitle', - sortDirection: sortDirections.ASCENDING, - secondarySortKey: 'sortTitle', - secondarySortDirection: sortDirections.ASCENDING, - - tableOptions: { - showSearchAction: false - }, - - columns: [ - { - name: 'select', - columnLabel: 'Select', - isSortable: false, - isVisible: true, - isModifiable: false, - isHidden: true - }, - { - name: 'status', - columnLabel: translate('ReleaseStatus'), - isSortable: true, - isVisible: true, - isModifiable: false - }, - { - name: 'sortTitle', - label: translate('MovieTitle'), - isSortable: true, - isVisible: true, - isModifiable: false - }, - { - name: 'collection', - label: translate('Collection'), - isSortable: true, - isVisible: false - }, - { - name: 'studio', - label: translate('Studio'), - isSortable: true, - isVisible: true - }, - { - name: 'qualityProfileId', - label: translate('QualityProfile'), - isSortable: true, - isVisible: true - }, - { - name: 'added', - label: translate('Added'), - isSortable: true, - isVisible: false - }, - { - name: 'year', - label: translate('Year'), - isSortable: true, - isVisible: false - }, - { - name: 'inCinemas', - label: translate('InCinemas'), - isSortable: true, - isVisible: false - }, - { - name: 'digitalRelease', - label: translate('DigitalRelease'), - isSortable: true, - isVisible: false - }, - { - name: 'physicalRelease', - label: translate('PhysicalRelease'), - isSortable: true, - isVisible: false - }, - { - name: 'runtime', - label: translate('Runtime'), - isSortable: true, - isVisible: false - }, - { - name: 'minimumAvailability', - label: translate('MinAvailability'), - isSortable: true, - isVisible: false - }, - { - name: 'path', - label: translate('Path'), - isSortable: true, - isVisible: false - }, - { - name: 'sizeOnDisk', - label: translate('SizeOnDisk'), - isSortable: true, - isVisible: false - }, - { - name: 'genres', - label: translate('Genres'), - isSortable: false, - isVisible: false - }, - { - name: 'movieStatus', - label: translate('Status'), - isSortable: true, - isVisible: true - }, - { - name: 'ratings', - label: translate('Ratings'), - isSortable: true, - isVisible: false - }, - { - name: 'certification', - label: translate('Certification'), - isSortable: true, - isVisible: false - }, - { - name: 'tags', - label: translate('Tags'), - isSortable: false, - isVisible: false - }, - { - name: 'actions', - columnLabel: translate('Actions'), - isVisible: true, - isModifiable: false - } - ], - - sortPredicates: { - ...sortPredicates, - - studio: function(item) { - const studio = item.studio; - - return studio ? studio.toLowerCase() : ''; - }, - - collection: function(item) { - const { collection ={} } = item; - - return collection.name; - }, - - ratings: function(item) { - const { ratings = {} } = item; - - return ratings.value; - } - }, - - selectedFilterKey: 'all', - - filters, - filterPredicates, - - filterBuilderProps: [ - { - name: 'monitored', - label: translate('Monitored'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.BOOL - }, - { - name: 'title', - label: translate('Title'), - type: filterBuilderTypes.STRING - }, - { - name: 'status', - label: translate('Status'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.MOVIE_STATUS - }, - { - name: 'studio', - label: translate('Studio'), - type: filterBuilderTypes.EXACT, - optionsSelector: function(items) { - const tagList = items.reduce((acc, movie) => { - if (movie.studio) { - acc.push({ - id: movie.studio, - name: movie.studio - }); - } - - return acc; - }, []); - - return tagList.sort(sortByName); - } - }, - { - name: 'collection', - label: translate('Collection'), - type: filterBuilderTypes.ARRAY, - optionsSelector: function(items) { - const collectionList = items.reduce((acc, movie) => { - if (movie.collection) { - acc.push({ - id: movie.collection.name, - name: movie.collection.name - }); - } - - return acc; - }, []); - - return collectionList.sort(sortByName); - } - }, - { - name: 'qualityProfileId', - label: translate('QualityProfile'), - type: filterBuilderTypes.EXACT, - valueType: filterBuilderValueTypes.QUALITY_PROFILE - }, - { - name: 'added', - label: translate('Added'), - type: filterBuilderTypes.DATE, - valueType: filterBuilderValueTypes.DATE - }, - { - name: 'year', - label: translate('Year'), - type: filterBuilderTypes.NUMBER - }, - { - name: 'inCinemas', - label: translate('InCinemas'), - type: filterBuilderTypes.DATE, - valueType: filterBuilderValueTypes.DATE - }, - { - name: 'physicalRelease', - label: translate('PhysicalRelease'), - type: filterBuilderTypes.DATE, - valueType: filterBuilderValueTypes.DATE - }, - { - name: 'digitalRelease', - label: translate('DigitalRelease'), - type: filterBuilderTypes.DATE, - valueType: filterBuilderValueTypes.DATE - }, - { - name: 'runtime', - label: translate('Runtime'), - type: filterBuilderTypes.NUMBER - }, - { - name: 'path', - label: translate('Path'), - type: filterBuilderTypes.STRING - }, - { - name: 'sizeOnDisk', - label: translate('SizeOnDisk'), - type: filterBuilderTypes.NUMBER, - valueType: filterBuilderValueTypes.BYTES - }, - { - name: 'genres', - label: translate('Genres'), - type: filterBuilderTypes.ARRAY, - optionsSelector: function(items) { - const genreList = items.reduce((acc, movie) => { - movie.genres.forEach((genre) => { - acc.push({ - id: genre, - name: genre - }); - }); - - return acc; - }, []); - - return genreList.sort(sortByName); - } - }, - { - name: 'ratings', - label: translate('Ratings'), - type: filterBuilderTypes.NUMBER - }, - { - name: 'certification', - label: translate('Certification'), - type: filterBuilderTypes.EXACT, - optionsSelector: function(items) { - const certificationList = items.reduce((acc, movie) => { - if (movie.certification) { - acc.push({ - id: movie.certification, - name: movie.certification - }); - } - - return acc; - }, []); - - return certificationList.sort(sortByName); - } - }, - { - name: 'tags', - label: translate('Tags'), - type: filterBuilderTypes.ARRAY, - valueType: filterBuilderValueTypes.TAG - } - ] -}; - -export const persistState = [ - 'movieIndex.sortKey', - 'movieIndex.sortDirection', - 'movieIndex.selectedFilterKey', - 'movieIndex.customFilters', - 'movieIndex.view', - 'movieIndex.columns', - 'movieIndex.posterOptions', - 'movieIndex.overviewOptions', - 'movieIndex.tableOptions' -]; - -// -// Actions Types - -export const SET_MOVIE_SORT = 'movieIndex/setMovieSort'; -export const SET_MOVIE_FILTER = 'movieIndex/setMovieFilter'; -export const SET_MOVIE_VIEW = 'movieIndex/setMovieView'; -export const SET_MOVIE_TABLE_OPTION = 'movieIndex/setMovieTableOption'; -export const SET_MOVIE_POSTER_OPTION = 'movieIndex/setMoviePosterOption'; -export const SET_MOVIE_OVERVIEW_OPTION = 'movieIndex/setMovieOverviewOption'; -export const SAVE_MOVIE_EDITOR = 'movieIndex/saveMovieEditor'; -export const BULK_DELETE_MOVIE = 'movieIndex/bulkDeleteMovie'; - -// -// Action Creators - -export const setMovieSort = createAction(SET_MOVIE_SORT); -export const setMovieFilter = createAction(SET_MOVIE_FILTER); -export const setMovieView = createAction(SET_MOVIE_VIEW); -export const setMovieTableOption = createAction(SET_MOVIE_TABLE_OPTION); -export const setMoviePosterOption = createAction(SET_MOVIE_POSTER_OPTION); -export const setMovieOverviewOption = createAction(SET_MOVIE_OVERVIEW_OPTION); -export const saveMovieEditor = createThunk(SAVE_MOVIE_EDITOR); -export const bulkDeleteMovie = createThunk(BULK_DELETE_MOVIE); - -// -// Action Handlers - -export const actionHandlers = handleThunks({ - [SAVE_MOVIE_EDITOR]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isSaving: true - })); - - const promise = createAjaxRequest({ - url: '/movie/editor', - method: 'PUT', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done((data) => { - dispatch(batchActions([ - ...data.map((movie) => { - return updateItem({ - id: movie.id, - section: 'movies', - ...movie - }); - }), - - set({ - section, - isSaving: false, - saveError: null - }) - ])); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isSaving: false, - saveError: xhr - })); - }); - }, - - [BULK_DELETE_MOVIE]: function(getState, payload, dispatch) { - dispatch(set({ - section, - isDeleting: true - })); - - const promise = createAjaxRequest({ - url: '/movie/editor', - method: 'DELETE', - data: JSON.stringify(payload), - dataType: 'json' - }).request; - - promise.done(() => { - // SignaR will take care of removing the movie from the collection - - dispatch(set({ - section, - isDeleting: false, - deleteError: null - })); - }); - - promise.fail((xhr) => { - dispatch(set({ - section, - isDeleting: false, - deleteError: xhr - })); - }); - } -}); - -// -// Reducers - -export const reducers = createHandleActions({ - - [SET_MOVIE_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_MOVIE_FILTER]: createSetClientSideCollectionFilterReducer(section), - - [SET_MOVIE_VIEW]: function(state, { payload }) { - return Object.assign({}, state, { view: payload.view }); - }, - - [SET_MOVIE_TABLE_OPTION]: createSetTableOptionReducer(section), - - [SET_MOVIE_POSTER_OPTION]: function(state, { payload }) { - const posterOptions = state.posterOptions; - - return { - ...state, - posterOptions: { - ...posterOptions, - ...payload - } - }; - }, - - [SET_MOVIE_OVERVIEW_OPTION]: function(state, { payload }) { - const overviewOptions = state.overviewOptions; - - return { - ...state, - overviewOptions: { - ...overviewOptions, - ...payload - } - }; - } - -}, defaultState, section); diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index a7ee93b41..2339c74f3 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -1,5 +1,7 @@ +import React from 'react'; import { createAction } from 'redux-actions'; -import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, icons, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; import translate from 'Utilities/String/translate'; @@ -7,6 +9,7 @@ import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; // // Variables @@ -25,6 +28,59 @@ export const defaultState = { items: [], sortKey: 'releaseWeight', sortDirection: sortDirections.ASCENDING, + + 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: 'size', + label: translate('Size'), + isSortable: true, + isVisible: true + }, + { + name: 'peers', + label: translate('Peers'), + isSortable: true, + isVisible: true + }, + { + name: 'indexerFlags', + label: React.createElement(Icon, { name: icons.FLAG }), + columnLabel: 'Indexer Flags', + isSortable: true, + isVisible: true + }, + { + name: 'actions', + columnLabel: translate('Actions'), + isVisible: true, + isModifiable: false + } + ], + sortPredicates: { age: function(item, direction) { return item.ageMinutes; @@ -180,6 +236,7 @@ export const CLEAR_RELEASES = 'releases/clearReleases'; export const GRAB_RELEASE = 'releases/grabRelease'; export const UPDATE_RELEASE = 'releases/updateRelease'; export const SET_RELEASES_FILTER = 'releases/setMovieReleasesFilter'; +export const SET_RELEASES_TABLE_OPTION = 'releases/setReleasesTableOption'; // // Action Creators @@ -191,11 +248,12 @@ export const clearReleases = createAction(CLEAR_RELEASES); export const grabRelease = createThunk(GRAB_RELEASE); export const updateRelease = createAction(UPDATE_RELEASE); export const setReleasesFilter = createAction(SET_RELEASES_FILTER); +export const setReleasesTableOption = createAction(SET_RELEASES_TABLE_OPTION); // // Helpers -const fetchReleasesHelper = createFetchHandler(section, '/release'); +const fetchReleasesHelper = createFetchHandler(section, '/search'); // // Action Handlers @@ -275,6 +333,8 @@ export const reducers = createHandleActions({ }, [SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section), - [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section) + [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), + + [SET_RELEASES_TABLE_OPTION]: createSetTableOptionReducer(section) }, defaultState, section); diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index f5b5cb4c1..f4528cfff 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -4,7 +4,6 @@ import createHandleActions from './Creators/createHandleActions'; import general from './Settings/general'; import indexerFlags from './Settings/indexerFlags'; import indexerOptions from './Settings/indexerOptions'; -import indexers from './Settings/indexers'; import languages from './Settings/languages'; import notifications from './Settings/notifications'; import ui from './Settings/ui'; @@ -12,7 +11,6 @@ import ui from './Settings/ui'; export * from './Settings/general'; export * from './Settings/indexerFlags'; export * from './Settings/indexerOptions'; -export * from './Settings/indexers'; export * from './Settings/languages'; export * from './Settings/notifications'; export * from './Settings/ui'; @@ -31,7 +29,6 @@ export const defaultState = { general: general.defaultState, indexerFlags: indexerFlags.defaultState, indexerOptions: indexerOptions.defaultState, - indexers: indexers.defaultState, languages: languages.defaultState, notifications: notifications.defaultState, ui: ui.defaultState @@ -58,7 +55,6 @@ export const actionHandlers = handleThunks({ ...general.actionHandlers, ...indexerFlags.actionHandlers, ...indexerOptions.actionHandlers, - ...indexers.actionHandlers, ...languages.actionHandlers, ...notifications.actionHandlers, ...ui.actionHandlers @@ -76,7 +72,6 @@ export const reducers = createHandleActions({ ...general.reducers, ...indexerFlags.reducers, ...indexerOptions.reducers, - ...indexers.reducers, ...languages.reducers, ...notifications.reducers, ...ui.reducers diff --git a/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js similarity index 69% rename from frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js rename to frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js index 755816c9d..931bddf23 100644 --- a/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js +++ b/frontend/src/Store/Selectors/createIndexerClientSideCollectionItemsSelector.js @@ -4,22 +4,22 @@ import createClientSideCollectionSelector from './createClientSideCollectionSele function createUnoptimizedSelector(uiSection) { return createSelector( - createClientSideCollectionSelector('movies', uiSection), - (movies) => { - const items = movies.items.map((s) => { + createClientSideCollectionSelector('indexers', uiSection), + (indexers) => { + const items = indexers.items.map((s) => { const { id, - sortTitle + name } = s; return { id, - sortTitle + sortTitle: name }; }); return { - ...movies, + ...indexers, items }; } @@ -35,11 +35,11 @@ const createMovieEqualSelector = createSelectorCreator( movieListEqual ); -function createMovieClientSideCollectionItemsSelector(uiSection) { +function createIndexerClientSideCollectionItemsSelector(uiSection) { return createMovieEqualSelector( createUnoptimizedSelector(uiSection), (movies) => movies ); } -export default createMovieClientSideCollectionItemsSelector; +export default createIndexerClientSideCollectionItemsSelector; diff --git a/frontend/src/Store/Selectors/createMovieSelector.js b/frontend/src/Store/Selectors/createIndexerSelector.js similarity index 66% rename from frontend/src/Store/Selectors/createMovieSelector.js rename to frontend/src/Store/Selectors/createIndexerSelector.js index caf55d7d3..48a410959 100644 --- a/frontend/src/Store/Selectors/createMovieSelector.js +++ b/frontend/src/Store/Selectors/createIndexerSelector.js @@ -1,10 +1,10 @@ import { createSelector } from 'reselect'; -function createMovieSelector() { +function createIndexerSelector() { return createSelector( (state, { movieId }) => movieId, - (state) => state.movies.itemMap, - (state) => state.movies.items, + (state) => state.indexers.itemMap, + (state) => state.indexers.items, (movieId, itemMap, allMovies) => { if (allMovies && itemMap && movieId in itemMap) { return allMovies[itemMap[movieId]]; @@ -14,4 +14,4 @@ function createMovieSelector() { ); } -export default createMovieSelector; +export default createIndexerSelector; diff --git a/frontend/src/Store/Selectors/createProviderSelector.js b/frontend/src/Store/Selectors/createProviderSelector.js new file mode 100644 index 000000000..f41292ee8 --- /dev/null +++ b/frontend/src/Store/Selectors/createProviderSelector.js @@ -0,0 +1,63 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; + +function createProviderSelector(sectionName) { + return createSelector( + (state, { id }) => id, + (state) => state[sectionName], + (id, section) => { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; + } + ); +} + +export default createProviderSelector; diff --git a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js new file mode 100644 index 000000000..9e6f3721c --- /dev/null +++ b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js @@ -0,0 +1,45 @@ +import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import createClientSideCollectionSelector from './createClientSideCollectionSelector'; + +function createUnoptimizedSelector(uiSection) { + return createSelector( + createClientSideCollectionSelector('releases', uiSection), + (releases) => { + const items = releases.items.map((s) => { + const { + guid, + title + } = s; + + return { + guid, + sortTitle: title + }; + }); + + return { + ...releases, + items + }; + } + ); +} + +function movieListEqual(a, b) { + return hasDifferentItemsOrOrder(a, b); +} + +const createMovieEqualSelector = createSelectorCreator( + defaultMemoize, + movieListEqual +); + +function createReleaseClientSideCollectionItemsSelector(uiSection) { + return createMovieEqualSelector( + createUnoptimizedSelector(uiSection), + (movies) => movies + ); +} + +export default createReleaseClientSideCollectionItemsSelector; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js index 99a558b4d..4f926b7c0 100644 --- a/frontend/src/Store/scrollPositions.js +++ b/frontend/src/Store/scrollPositions.js @@ -1,6 +1,5 @@ const scrollPositions = { - movieIndex: 0, - discoverMovie: 0 + movieIndex: 0 }; export default scrollPositions; diff --git a/frontend/src/System/Status/Health/HealthConnector.js b/frontend/src/System/Status/Health/HealthConnector.js index 2aa6fba9b..885faa424 100644 --- a/frontend/src/System/Status/Health/HealthConnector.js +++ b/frontend/src/System/Status/Health/HealthConnector.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { testAllIndexers } from 'Store/Actions/settingsActions'; +import { testAllIndexers } from 'Store/Actions/indexerActions'; import { fetchHealth } from 'Store/Actions/systemActions'; import createHealthCheckSelector from 'Store/Selectors/createHealthCheckSelector'; import Health from './Health'; @@ -11,7 +11,7 @@ function createMapStateToProps() { return createSelector( createHealthCheckSelector(), (state) => state.system.health, - (state) => state.settings.indexers.isTestingAll, + (state) => state.indexers.isTestingAll, (items, health, isTestingAllIndexers) => { const { isFetching, diff --git a/src/NzbDrone.Automation.Test/MainPagesTest.cs b/src/NzbDrone.Automation.Test/MainPagesTest.cs index 713a7144c..4b42be06c 100644 --- a/src/NzbDrone.Automation.Test/MainPagesTest.cs +++ b/src/NzbDrone.Automation.Test/MainPagesTest.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Automation.Test } [Test] - public void movie_page() + public void indexer_page() { _page.MovieNavIcon.Click(); _page.WaitForNoSpinner(); @@ -26,33 +26,7 @@ namespace NzbDrone.Automation.Test var imageName = MethodBase.GetCurrentMethod().Name; TakeScreenshot(imageName); - _page.Find(By.CssSelector("div[class*='MovieIndex']")).Should().NotBeNull(); - } - - [Test] - public void calendar_page() - { - _page.CalendarNavIcon.Click(); - _page.WaitForNoSpinner(); - - var imageName = MethodBase.GetCurrentMethod().Name; - TakeScreenshot(imageName); - - _page.Find(By.CssSelector("div[class*='CalendarPage']")).Should().NotBeNull(); - } - - [Test] - public void activity_page() - { - _page.ActivityNavIcon.Click(); - _page.WaitForNoSpinner(); - - var imageName = MethodBase.GetCurrentMethod().Name; - TakeScreenshot(imageName); - - _page.Find(By.LinkText("Queue")).Should().NotBeNull(); - _page.Find(By.LinkText("History")).Should().NotBeNull(); - _page.Find(By.LinkText("Blacklist")).Should().NotBeNull(); + _page.Find(By.CssSelector("div[class*='IndexerIndex']")).Should().NotBeNull(); } [Test] @@ -66,19 +40,5 @@ namespace NzbDrone.Automation.Test _page.Find(By.CssSelector("div[class*='Health']")).Should().NotBeNull(); } - - [Test] - public void add_movie_page() - { - _page.MovieNavIcon.Click(); - _page.WaitForNoSpinner(); - _page.Find(By.LinkText("Add New")).Click(); - _page.WaitForNoSpinner(); - - var imageName = MethodBase.GetCurrentMethod().Name; - TakeScreenshot(imageName); - - _page.Find(By.CssSelector("input[class*='AddNewMovie-searchInput']")).Should().NotBeNull(); - } } } diff --git a/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs index 4cc12f77b..cb4dacb88 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/IndexerServiceFixture.cs @@ -1,10 +1,9 @@ -using System.Collections.Generic; +using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers.Newznab; -using NzbDrone.Core.Indexers.Omgwtfnzbs; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Test.Framework; @@ -20,7 +19,6 @@ namespace NzbDrone.Core.Test.IndexerTests _indexers = new List(); _indexers.Add(Mocker.Resolve()); - _indexers.Add(Mocker.Resolve()); Mocker.SetConstant>(_indexers); } diff --git a/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs b/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs deleted file mode 100644 index 2e6ff1f83..000000000 --- a/src/NzbDrone.Core.Test/IndexerTests/OmgwtfnzbsTests/OmgwtfnzbsFixture.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.Linq; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Http; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Indexers.Omgwtfnzbs; -using NzbDrone.Core.Test.Framework; - -namespace NzbDrone.Core.Test.IndexerTests.OmgwtfnzbsTests -{ - [TestFixture] - public class OmgwtfnzbsFixture : CoreTest - { - [SetUp] - public void Setup() - { - Subject.Definition = new IndexerDefinition() - { - Name = "Omgwtfnzbs", - Settings = new OmgwtfnzbsSettings() - { - ApiKey = "xxx", - Username = "me@my.domain" - } - }; - } - - [Test] - public void should_parse_recent_feed_from_omgwtfnzbs() - { - var recentFeed = ReadAllText(@"Files/Indexers/Omgwtfnzbs/Omgwtfnzbs.xml"); - - Mocker.GetMock() - .Setup(o => o.Execute(It.Is(v => v.Method == HttpMethod.GET))) - .Returns(r => new HttpResponse(r, new HttpHeader(), recentFeed)); - - var releases = Subject.FetchRecent(); - - releases.Should().HaveCount(100); - - var releaseInfo = releases.First(); - - releaseInfo.Title.Should().Be("Un.Petit.Boulot.2016.FRENCH.720p.BluRay.DTS.x264-LOST"); - releaseInfo.DownloadProtocol.Should().Be(DownloadProtocol.Usenet); - releaseInfo.DownloadUrl.Should().Be("https://api.omgwtfnzbs.me/nzb/?id=8a2Bw&user=nzbdrone&api=nzbdrone"); - releaseInfo.InfoUrl.Should().Be("https://omgwtfnzbs.me/details.php?id=8a2Bw"); - releaseInfo.CommentUrl.Should().BeNullOrEmpty(); - releaseInfo.Indexer.Should().Be(Subject.Definition.Name); - releaseInfo.PublishDate.Should().Be(DateTime.Parse("2017/01/09 00:16:54")); - releaseInfo.Size.Should().Be(5354909355); - } - } -} diff --git a/src/NzbDrone.Core.Test/IndexerTests/TestIndexer.cs b/src/NzbDrone.Core.Test/IndexerTests/TestIndexer.cs index 0e231deac..4654a6f6d 100644 --- a/src/NzbDrone.Core.Test/IndexerTests/TestIndexer.cs +++ b/src/NzbDrone.Core.Test/IndexerTests/TestIndexer.cs @@ -2,16 +2,17 @@ using NLog; using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; using NzbDrone.Core.Indexers; -using NzbDrone.Core.Parser; namespace NzbDrone.Core.Test.IndexerTests { - public class TestIndexer : HttpApplicationBase + public class TestIndexer : HttpIndexerBase { public override string Name => "Test Indexer"; public override DownloadProtocol Protocol => DownloadProtocol.Usenet; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; + public int _supportedPageSize; public override int PageSize => _supportedPageSize; diff --git a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs index e144aebea..bde007530 100644 --- a/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs +++ b/src/NzbDrone.Core/Datastore/Migration/001_initial_setup.cs @@ -44,7 +44,10 @@ namespace NzbDrone.Core.Datastore.Migration .WithColumn("Settings").AsString().Nullable() .WithColumn("ConfigContract").AsString().Nullable() .WithColumn("EnableRss").AsBoolean().Nullable() - .WithColumn("EnableSearch").AsBoolean().Nullable(); + .WithColumn("EnableAutomaticSearch").AsBoolean().Nullable() + .WithColumn("EnableInteractiveSearch").AsBoolean().NotNullable() + .WithColumn("Priority").AsInt32().NotNullable().WithDefaultValue(25) + .WithColumn("Added").AsDateTime(); Create.TableForModel("Applications") .WithColumn("Name").AsString().Unique() @@ -73,7 +76,7 @@ namespace NzbDrone.Core.Datastore.Migration .WithColumn("Trigger").AsInt32().NotNullable(); Create.TableForModel("IndexerStatus") - .WithColumn("IndexerId").AsInt32().NotNullable().Unique() + .WithColumn("ProviderId").AsInt32().NotNullable().Unique() .WithColumn("InitialFailure").AsDateTime().Nullable() .WithColumn("MostRecentFailure").AsDateTime().Nullable() .WithColumn("EscalationLevel").AsInt32().NotNullable() diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index da8cf6bf9..0e78821d1 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -42,8 +42,13 @@ namespace NzbDrone.Core.Datastore .Ignore(x => x.ImplementationName) .Ignore(i => i.Enable) .Ignore(i => i.Protocol) + .Ignore(i => i.Privacy) .Ignore(i => i.SupportsRss) .Ignore(i => i.SupportsSearch) + .Ignore(i => i.SupportsBooks) + .Ignore(i => i.SupportsMusic) + .Ignore(i => i.SupportsMovies) + .Ignore(i => i.SupportsTv) .Ignore(d => d.Tags); Mapper.Entity("Notifications").RegisterModel() diff --git a/src/NzbDrone.Core/Indexers/AwesomeHD/AwesomeHD.cs b/src/NzbDrone.Core/Indexers/AwesomeHD/AwesomeHD.cs index 1603d5227..9cb8fc114 100644 --- a/src/NzbDrone.Core/Indexers/AwesomeHD/AwesomeHD.cs +++ b/src/NzbDrone.Core/Indexers/AwesomeHD/AwesomeHD.cs @@ -4,12 +4,15 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.AwesomeHD { - public class AwesomeHD : HttpApplicationBase + public class AwesomeHD : HttpIndexerBase { public override string Name => "AwesomeHD"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsRss => true; public override bool SupportsSearch => true; + public override int PageSize => 50; public AwesomeHD(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/FileList/FileList.cs b/src/NzbDrone.Core/Indexers/FileList/FileList.cs index 1df00b0af..29da07be4 100644 --- a/src/NzbDrone.Core/Indexers/FileList/FileList.cs +++ b/src/NzbDrone.Core/Indexers/FileList/FileList.cs @@ -4,10 +4,11 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.FileList { - public class FileList : HttpApplicationBase + public class FileList : HttpIndexerBase { public override string Name => "FileList.io"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsRss => true; public override bool SupportsSearch => true; diff --git a/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs b/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs index 2f70aa083..bfbf82d68 100644 --- a/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs +++ b/src/NzbDrone.Core/Indexers/HDBits/HDBits.cs @@ -4,12 +4,12 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.HDBits { - public class HDBits : HttpApplicationBase + public class HDBits : HttpIndexerBase { public override string Name => "HDBits"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; - public override bool SupportsRss => true; - public override bool SupportsSearch => true; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; + public override int PageSize => 30; public HDBits(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs index fa02f1079..67ed01e75 100644 --- a/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/HttpIndexerBase.cs @@ -14,7 +14,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Indexers { - public abstract class HttpApplicationBase : IndexerBase + public abstract class HttpIndexerBase : IndexerBase where TSettings : IIndexerSettings, new() { protected const int MaxNumResultsPerQuery = 1000; @@ -23,6 +23,11 @@ namespace NzbDrone.Core.Indexers public override bool SupportsRss => true; public override bool SupportsSearch => true; + public override bool SupportsMusic => true; + public override bool SupportsTv => true; + public override bool SupportsMovies => true; + public override bool SupportsBooks => true; + public bool SupportsPaging => PageSize > 0; public virtual int PageSize => 0; @@ -31,7 +36,7 @@ namespace NzbDrone.Core.Indexers public abstract IIndexerRequestGenerator GetRequestGenerator(); public abstract IParseIndexerResponse GetParser(); - public HttpApplicationBase(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) + public HttpIndexerBase(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) : base(indexerStatusService, configService, logger) { _httpClient = httpClient; diff --git a/src/NzbDrone.Core/Indexers/IIndexer.cs b/src/NzbDrone.Core/Indexers/IIndexer.cs index 2e3200162..bff0e0f44 100644 --- a/src/NzbDrone.Core/Indexers/IIndexer.cs +++ b/src/NzbDrone.Core/Indexers/IIndexer.cs @@ -9,7 +9,13 @@ namespace NzbDrone.Core.Indexers { bool SupportsRss { get; } bool SupportsSearch { get; } + bool SupportsMusic { get; } + bool SupportsTv { get; } + bool SupportsMovies { get; } + bool SupportsBooks { get; } + DownloadProtocol Protocol { get; } + IndexerPrivacy Privacy { get; } IList FetchRecent(); IList Fetch(MovieSearchCriteria searchCriteria); diff --git a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs index 5f5f93249..cd5dfa28c 100644 --- a/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs +++ b/src/NzbDrone.Core/Indexers/IPTorrents/IPTorrents.cs @@ -4,12 +4,14 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.IPTorrents { - public class IPTorrents : HttpApplicationBase + public class IPTorrents : HttpIndexerBase { public override string Name => "IP Torrents"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsSearch => false; + public override int PageSize => 0; public IPTorrents(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/IndexerBase.cs b/src/NzbDrone.Core/Indexers/IndexerBase.cs index 7fef56fe4..c893e5ec9 100644 --- a/src/NzbDrone.Core/Indexers/IndexerBase.cs +++ b/src/NzbDrone.Core/Indexers/IndexerBase.cs @@ -20,11 +20,17 @@ namespace NzbDrone.Core.Indexers public abstract string Name { get; } public abstract DownloadProtocol Protocol { get; } + public abstract IndexerPrivacy Privacy { get; } public int Priority { get; set; } public abstract bool SupportsRss { get; } public abstract bool SupportsSearch { get; } + public abstract bool SupportsMusic { get; } + public abstract bool SupportsTv { get; } + public abstract bool SupportsMovies { get; } + public abstract bool SupportsBooks { get; } + public IndexerBase(IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) { _indexerStatusService = indexerStatusService; diff --git a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs index d43c18198..45ed46f77 100644 --- a/src/NzbDrone.Core/Indexers/IndexerDefinition.cs +++ b/src/NzbDrone.Core/Indexers/IndexerDefinition.cs @@ -1,4 +1,5 @@ -using NzbDrone.Core.ThingiProvider; +using System; +using NzbDrone.Core.ThingiProvider; namespace NzbDrone.Core.Indexers { @@ -8,9 +9,15 @@ namespace NzbDrone.Core.Indexers public bool EnableAutomaticSearch { get; set; } public bool EnableInteractiveSearch { get; set; } public DownloadProtocol Protocol { get; set; } + public IndexerPrivacy Privacy { get; set; } public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } + public bool SupportsMusic { get; set; } + public bool SupportsTv { get; set; } + public bool SupportsMovies { get; set; } + public bool SupportsBooks { get; set; } public int Priority { get; set; } = 25; + public DateTime Added { get; set; } public override bool Enable => EnableRss || EnableAutomaticSearch || EnableInteractiveSearch; diff --git a/src/NzbDrone.Core/Indexers/IndexerFactory.cs b/src/NzbDrone.Core/Indexers/IndexerFactory.cs index 902de54b9..2aaee0d6a 100644 --- a/src/NzbDrone.Core/Indexers/IndexerFactory.cs +++ b/src/NzbDrone.Core/Indexers/IndexerFactory.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using FluentValidation.Results; @@ -42,8 +43,13 @@ namespace NzbDrone.Core.Indexers base.SetProviderCharacteristics(provider, definition); definition.Protocol = provider.Protocol; + definition.Privacy = provider.Privacy; definition.SupportsRss = provider.SupportsRss; definition.SupportsSearch = provider.SupportsSearch; + definition.SupportsBooks = provider.SupportsBooks; + definition.SupportsMovies = provider.SupportsMovies; + definition.SupportsMusic = provider.SupportsMusic; + definition.SupportsTv = provider.SupportsTv; } public List RssEnabled(bool filterBlockedIndexers = true) @@ -110,5 +116,12 @@ namespace NzbDrone.Core.Indexers return result; } + + public override IndexerDefinition Create(IndexerDefinition definition) + { + definition.Added = DateTime.UtcNow; + + return base.Create(definition); + } } } diff --git a/src/NzbDrone.Core/Indexers/IndexerPrivacy.cs b/src/NzbDrone.Core/Indexers/IndexerPrivacy.cs new file mode 100644 index 000000000..0e467063e --- /dev/null +++ b/src/NzbDrone.Core/Indexers/IndexerPrivacy.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Indexers +{ + public enum IndexerPrivacy + { + Public, + SemiPublic, + Private + } +} diff --git a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs index d05b3f189..f9220628e 100644 --- a/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs +++ b/src/NzbDrone.Core/Indexers/Newznab/Newznab.cs @@ -11,13 +11,14 @@ using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Newznab { - public class Newznab : HttpApplicationBase + public class Newznab : HttpIndexerBase { private readonly INewznabCapabilitiesProvider _capabilitiesProvider; public override string Name => "Newznab"; public override DownloadProtocol Protocol => DownloadProtocol.Usenet; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).DefaultPageSize; @@ -72,8 +73,13 @@ namespace NzbDrone.Core.Indexers.Newznab Implementation = GetType().Name, Settings = settings, Protocol = DownloadProtocol.Usenet, + Privacy = IndexerPrivacy.Private, SupportsRss = SupportsRss, - SupportsSearch = SupportsSearch + SupportsSearch = SupportsSearch, + SupportsBooks = SupportsBooks, + SupportsMovies = SupportsMovies, + SupportsMusic = SupportsMusic, + SupportsTv = SupportsTv }; } diff --git a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs index 5b3ba8e8b..1b93fc616 100644 --- a/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs +++ b/src/NzbDrone.Core/Indexers/Nyaa/Nyaa.cs @@ -4,11 +4,13 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.Nyaa { - public class Nyaa : HttpApplicationBase + public class Nyaa : HttpIndexerBase { public override string Name => "Nyaa"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override int PageSize => 100; public Nyaa(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs deleted file mode 100644 index 1ad38160d..000000000 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/Omgwtfnzbs.cs +++ /dev/null @@ -1,28 +0,0 @@ -using NLog; -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.Indexers.Omgwtfnzbs -{ - public class Omgwtfnzbs : HttpApplicationBase - { - public override string Name => "omgwtfnzbs"; - - public override DownloadProtocol Protocol => DownloadProtocol.Usenet; - - public Omgwtfnzbs(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) - : base(httpClient, indexerStatusService, configService, logger) - { - } - - public override IIndexerRequestGenerator GetRequestGenerator() - { - return new OmgwtfnzbsRequestGenerator() { Settings = Settings }; - } - - public override IParseIndexerResponse GetParser() - { - return new OmgwtfnzbsRssParser(); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs deleted file mode 100644 index b2bf7d5aa..000000000 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRequestGenerator.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Http; -using NzbDrone.Core.IndexerSearch.Definitions; - -namespace NzbDrone.Core.Indexers.Omgwtfnzbs -{ - public class OmgwtfnzbsRequestGenerator : IIndexerRequestGenerator - { - public string BaseUrl { get; set; } - public OmgwtfnzbsSettings Settings { get; set; } - - public OmgwtfnzbsRequestGenerator() - { - BaseUrl = "https://rss.omgwtfnzbs.me/rss-download.php"; - } - - public virtual IndexerPageableRequestChain GetRecentRequests() - { - var pageableRequests = new IndexerPageableRequestChain(); - - pageableRequests.Add(GetPagedRequests(null)); - - return pageableRequests; - } - - public IndexerPageableRequestChain GetSearchRequests(MovieSearchCriteria searchCriteria) - { - var pageableRequests = new IndexerPageableRequestChain(); - - foreach (var queryTitle in searchCriteria.QueryTitles) - { - pageableRequests.Add(GetPagedRequests(string.Format("{0}", - queryTitle))); - } - - return pageableRequests; - } - - private IEnumerable GetPagedRequests(string query) - { - var url = new StringBuilder(); - url.AppendFormat("{0}?catid=15,16,17,18,31,35&user={1}&api={2}&eng=1&delay={3}", BaseUrl, Settings.Username, Settings.ApiKey, Settings.Delay); - - if (query.IsNotNullOrWhiteSpace()) - { - url = url.Replace("rss-download.php", "rss-search.php"); - url.AppendFormat("&search={0}", query); - } - - yield return new IndexerRequest(url.ToString(), HttpAccept.Rss); - } - - public Func> GetCookies { get; set; } - public Action, DateTime?> CookiesUpdater { get; set; } - } -} diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRssParser.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRssParser.cs deleted file mode 100644 index b3c345beb..000000000 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsRssParser.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Linq; -using System.Text.RegularExpressions; -using System.Xml.Linq; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Indexers.Exceptions; - -namespace NzbDrone.Core.Indexers.Omgwtfnzbs -{ - public class OmgwtfnzbsRssParser : RssParser - { - public OmgwtfnzbsRssParser() - { - UseEnclosureUrl = true; - UseEnclosureLength = true; - } - - protected override bool PreProcess(IndexerResponse indexerResponse) - { - var xdoc = LoadXmlDocument(indexerResponse); - var notice = xdoc.Descendants("notice").FirstOrDefault(); - - if (notice == null) - { - return true; - } - - if (!notice.Value.ContainsIgnoreCase("api")) - { - return true; - } - - throw new ApiKeyException(notice.Value); - } - - protected override string GetInfoUrl(XElement item) - { - //Todo: Me thinks I need to parse details to get this... - var match = Regex.Match(item.Description(), - @"(?:\View NZB\:\<\/b\>\s\.+?)(?:\"")", - RegexOptions.IgnoreCase | RegexOptions.Compiled); - - if (match.Success) - { - return match.Groups["URL"].Value; - } - - return string.Empty; - } - } -} diff --git a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs b/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs deleted file mode 100644 index 3e137cf37..000000000 --- a/src/NzbDrone.Core/Indexers/Omgwtfnzbs/OmgwtfnzbsSettings.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.Languages; -using NzbDrone.Core.Validation; - -namespace NzbDrone.Core.Indexers.Omgwtfnzbs -{ - public class OmgwtfnzbsSettingsValidator : AbstractValidator - { - public OmgwtfnzbsSettingsValidator() - { - RuleFor(c => c.Username).NotEmpty(); - RuleFor(c => c.ApiKey).NotEmpty(); - RuleFor(c => c.Delay).GreaterThanOrEqualTo(0); - } - } - - public class OmgwtfnzbsSettings : IIndexerSettings - { - private static readonly OmgwtfnzbsSettingsValidator Validator = new OmgwtfnzbsSettingsValidator(); - - public OmgwtfnzbsSettings() - { - Delay = 30; - MultiLanguages = new List(); - } - - // Unused since Omg has a hardcoded url. - public string BaseUrl { get; set; } - - [FieldDefinition(0, Label = "Username", Privacy = PrivacyLevel.UserName)] - public string Username { get; set; } - - [FieldDefinition(1, Label = "API Key", Privacy = PrivacyLevel.ApiKey)] - public string ApiKey { get; set; } - - [FieldDefinition(2, Label = "Delay", HelpText = "Time in minutes to delay new nzbs before they appear on the RSS feed", Advanced = true)] - public int Delay { get; set; } - - [FieldDefinition(3, Type = FieldType.Select, SelectOptions = typeof(LanguageFieldConverter), Label = "Multi Languages", HelpText = "What languages are normally in a multi release on this indexer?", Advanced = true)] - public IEnumerable MultiLanguages { get; set; } - - public NzbDroneValidationResult Validate() - { - return new NzbDroneValidationResult(Validator.Validate(this)); - } - } -} diff --git a/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs b/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs index 83a1084cf..c4d6bd970 100644 --- a/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs +++ b/src/NzbDrone.Core/Indexers/PassThePopcorn/PassThePopcorn.cs @@ -5,12 +5,17 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.PassThePopcorn { - public class PassThePopcorn : HttpApplicationBase + public class PassThePopcorn : HttpIndexerBase { public override string Name => "PassThePopcorn"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsRss => true; public override bool SupportsSearch => true; + public override bool SupportsMusic => false; + public override bool SupportsTv => false; + public override bool SupportsMovies => true; + public override bool SupportsBooks => false; public override int PageSize => 50; public PassThePopcorn(IHttpClient httpClient, diff --git a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs index 1e596ab85..19c674ccc 100644 --- a/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs +++ b/src/NzbDrone.Core/Indexers/Rarbg/Rarbg.cs @@ -10,13 +10,15 @@ using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Rarbg { - public class Rarbg : HttpApplicationBase + public class Rarbg : HttpIndexerBase { private readonly IRarbgTokenProvider _tokenProvider; public override string Name => "Rarbg"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + + public override IndexerPrivacy Privacy => IndexerPrivacy.Public; public override TimeSpan RateLimit => TimeSpan.FromSeconds(2); public Rarbg(IRarbgTokenProvider tokenProvider, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/TorrentPotato/TorrentPotato.cs b/src/NzbDrone.Core/Indexers/TorrentPotato/TorrentPotato.cs index cde5bc081..cbbcc99f2 100644 --- a/src/NzbDrone.Core/Indexers/TorrentPotato/TorrentPotato.cs +++ b/src/NzbDrone.Core/Indexers/TorrentPotato/TorrentPotato.cs @@ -5,11 +5,12 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.TorrentPotato { - public class TorrentPotato : HttpApplicationBase + public class TorrentPotato : HttpIndexerBase { public override string Name => "TorrentPotato"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override TimeSpan RateLimit => TimeSpan.FromSeconds(2); public TorrentPotato(IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, Logger logger) diff --git a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexer.cs b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexer.cs index 94b47c1ea..01bf6ba7b 100644 --- a/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexer.cs +++ b/src/NzbDrone.Core/Indexers/TorrentRss/TorrentRssIndexer.cs @@ -4,11 +4,12 @@ using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Indexers.TorrentRss { - public class TorrentRssIndexer : HttpApplicationBase + public class TorrentRssIndexer : HttpIndexerBase { public override string Name => "Torrent RSS Feed"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override bool SupportsSearch => false; public override int PageSize => 0; diff --git a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs index 4533d0b6f..0f265daed 100644 --- a/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs +++ b/src/NzbDrone.Core/Indexers/Torznab/Torznab.cs @@ -12,13 +12,14 @@ using NzbDrone.Core.Validation; namespace NzbDrone.Core.Indexers.Torznab { - public class Torznab : HttpApplicationBase + public class Torznab : HttpIndexerBase { private readonly INewznabCapabilitiesProvider _capabilitiesProvider; public override string Name => "Torznab"; public override DownloadProtocol Protocol => DownloadProtocol.Torrent; + public override IndexerPrivacy Privacy => IndexerPrivacy.Private; public override int PageSize => _capabilitiesProvider.GetCapabilities(Settings).DefaultPageSize; public override IIndexerRequestGenerator GetRequestGenerator() diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index c9f2d9546..5c499fd07 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -15,7 +15,7 @@ "AddMoviesMonitored": "Add Movies Monitored", "AddNew": "Add New", "AddNewMessage": "It's easy to add a new movie, just start typing the name of the movie you want to add", - "AddNewMovie": "Add New Movie", + "AddNewIndexer": "Add New Indexer", "AddNewTmdbIdMessage": "You can also search using TMDb Id of a movie. eg. tmdb:71663", "AddRemotePathMapping": "Add Remote Path Mapping", "AddRestriction": "Add Restriction", @@ -23,7 +23,7 @@ "Agenda": "Agenda", "AgeWhenGrabbed": "Age (when grabbed)", "All": "All", - "AllMoviesHiddenDueToFilter": "All movies are hidden due to applied filter.", + "AllIndexersHiddenDueToFilter": "All indexers are hidden due to applied filter.", "AllowHardcodedSubs": "Allow Hardcoded Subs", "AllowHardcodedSubsHelpText": "Detected hardcoded subs will be automatically downloaded", "AllowMovieChangeClickToChangeMovie": "Click to change movie", @@ -452,7 +452,7 @@ "MovieIsUnmonitored": "Movie is unmonitored", "MovieNaming": "Movie Naming", "Movies": "Movies", - "MoviesSelectedInterp": "{0} Movie(s) Selected", + "IndexersSelectedInterp": "{0} Indexer(s) Selected", "MovieTitle": "Movie Title", "MovieTitleHelpText": "The title of the movie to exclude (can be anything meaningful)", "MovieYear": "Movie Year", diff --git a/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs b/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs index b03663b69..7a3a7cd20 100644 --- a/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs +++ b/src/Prowlarr.Api.V1/Indexers/IndexerResource.cs @@ -1,3 +1,4 @@ +using System; using NzbDrone.Core.Indexers; namespace Prowlarr.Api.V1.Indexers @@ -9,8 +10,14 @@ namespace Prowlarr.Api.V1.Indexers public bool EnableInteractiveSearch { get; set; } public bool SupportsRss { get; set; } public bool SupportsSearch { get; set; } + public bool SupportsMusic { get; set; } + public bool SupportsTv { get; set; } + public bool SupportsMovies { get; set; } + public bool SupportsBooks { get; set; } public DownloadProtocol Protocol { get; set; } + public IndexerPrivacy Privacy { get; set; } public int Priority { get; set; } + public DateTime Added { get; set; } } public class IndexerResourceMapper : ProviderResourceMapper @@ -29,8 +36,14 @@ namespace Prowlarr.Api.V1.Indexers resource.EnableInteractiveSearch = definition.EnableInteractiveSearch; resource.SupportsRss = definition.SupportsRss; resource.SupportsSearch = definition.SupportsSearch; + resource.SupportsBooks = definition.SupportsBooks; + resource.SupportsMovies = definition.SupportsMovies; + resource.SupportsMusic = definition.SupportsMusic; + resource.SupportsTv = definition.SupportsTv; resource.Protocol = definition.Protocol; + resource.Privacy = definition.Privacy; resource.Priority = definition.Priority; + resource.Added = definition.Added; return resource; } @@ -48,6 +61,8 @@ namespace Prowlarr.Api.V1.Indexers definition.EnableAutomaticSearch = resource.EnableAutomaticSearch; definition.EnableInteractiveSearch = resource.EnableInteractiveSearch; definition.Priority = resource.Priority; + definition.Privacy = resource.Privacy; + definition.Added = resource.Added; return definition; } diff --git a/src/Prowlarr.Api.V1/Search/SearchModule.cs b/src/Prowlarr.Api.V1/Search/SearchModule.cs new file mode 100644 index 000000000..1e9bba7dd --- /dev/null +++ b/src/Prowlarr.Api.V1/Search/SearchModule.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Net; +using NLog; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.IndexerSearch; +using NzbDrone.Core.Parser.Model; +using Prowlarr.Http; + +namespace Prowlarr.Api.V1.Search +{ + public class SearchModule : ProwlarrRestModule + { + private readonly ISearchForNzb _nzbSearhService; + private readonly Logger _logger; + + public SearchModule(ISearchForNzb nzbSearhService, Logger logger) + { + _nzbSearhService = nzbSearhService; + _logger = logger; + + GetResourceAll = GetAll; + } + + private List GetAll() + { + if (Request.Query.query.HasValue) + { + return GetSearchReleases(Request.Query.query); + } + + return new List(); + } + + private List GetSearchReleases(string query) + { + try + { + var decisions = _nzbSearhService.MovieSearch(query, true, true); + + return MapDecisions(decisions); + } + catch (SearchFailedException ex) + { + throw new NzbDroneClientException(HttpStatusCode.BadRequest, ex.Message); + } + catch (Exception ex) + { + _logger.Error(ex, "Search failed: " + ex.Message); + } + + return new List(); + } + + protected virtual List MapDecisions(IEnumerable releases) + { + var result = new List(); + + foreach (var downloadDecision in releases) + { + var release = downloadDecision.ToResource(); + + result.Add(release); + } + + return result; + } + } +} diff --git a/src/Prowlarr.Api.V1/Search/SearchResource.cs b/src/Prowlarr.Api.V1/Search/SearchResource.cs new file mode 100644 index 000000000..47b480297 --- /dev/null +++ b/src/Prowlarr.Api.V1/Search/SearchResource.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using Prowlarr.Http.REST; + +namespace Prowlarr.Api.V1.Search +{ + public class SearchResource : RestResource + { + public string Guid { get; set; } + public int Age { get; set; } + public double AgeHours { get; set; } + public double AgeMinutes { get; set; } + public long Size { get; set; } + public int IndexerId { get; set; } + public string Indexer { get; set; } + public string SubGroup { get; set; } + public string ReleaseHash { get; set; } + public string Title { get; set; } + public bool Approved { get; set; } + public int ImdbId { get; set; } + public DateTime PublishDate { get; set; } + public string CommentUrl { get; set; } + public string DownloadUrl { get; set; } + public string InfoUrl { get; set; } + public IEnumerable IndexerFlags { get; set; } + + public string MagnetUrl { get; set; } + public string InfoHash { get; set; } + public int? Seeders { get; set; } + public int? Leechers { get; set; } + public DownloadProtocol Protocol { get; set; } + } + + public static class ReleaseResourceMapper + { + public static SearchResource ToResource(this ReleaseInfo model) + { + var releaseInfo = model; + var torrentInfo = (model as TorrentInfo) ?? new TorrentInfo(); + var indexerFlags = torrentInfo.IndexerFlags.ToString().Split(new string[] { ", " }, StringSplitOptions.None).Where(x => x != "0"); + + // TODO: Clean this mess up. don't mix data from multiple classes, use sub-resources instead? (Got a huge Deja Vu, didn't we talk about this already once?) + return new SearchResource + { + Guid = releaseInfo.Guid, + + //QualityWeight + Age = releaseInfo.Age, + AgeHours = releaseInfo.AgeHours, + AgeMinutes = releaseInfo.AgeMinutes, + Size = releaseInfo.Size, + IndexerId = releaseInfo.IndexerId, + Indexer = releaseInfo.Indexer, + Title = releaseInfo.Title, + ImdbId = releaseInfo.ImdbId, + PublishDate = releaseInfo.PublishDate, + CommentUrl = releaseInfo.CommentUrl, + DownloadUrl = releaseInfo.DownloadUrl, + InfoUrl = releaseInfo.InfoUrl, + + //ReleaseWeight + MagnetUrl = torrentInfo.MagnetUrl, + InfoHash = torrentInfo.InfoHash, + Seeders = torrentInfo.Seeders, + Leechers = (torrentInfo.Peers.HasValue && torrentInfo.Seeders.HasValue) ? (torrentInfo.Peers.Value - torrentInfo.Seeders.Value) : (int?)null, + Protocol = releaseInfo.DownloadProtocol, + IndexerFlags = indexerFlags + }; + } + + public static ReleaseInfo ToModel(this SearchResource resource) + { + ReleaseInfo model; + + if (resource.Protocol == DownloadProtocol.Torrent) + { + model = new TorrentInfo + { + MagnetUrl = resource.MagnetUrl, + InfoHash = resource.InfoHash, + Seeders = resource.Seeders, + Peers = (resource.Seeders.HasValue && resource.Leechers.HasValue) ? (resource.Seeders + resource.Leechers) : null + }; + } + else + { + model = new ReleaseInfo(); + } + + model.Guid = resource.Guid; + model.Title = resource.Title; + model.Size = resource.Size; + model.DownloadUrl = resource.DownloadUrl; + model.InfoUrl = resource.InfoUrl; + model.CommentUrl = resource.CommentUrl; + model.IndexerId = resource.IndexerId; + model.Indexer = resource.Indexer; + model.DownloadProtocol = resource.Protocol; + model.ImdbId = resource.ImdbId; + model.PublishDate = resource.PublishDate.ToUniversalTime(); + + return model; + } + } +}