diff --git a/frontend/build/webpack.config.js b/frontend/build/webpack.config.js index 6f324ac95..e1566a75c 100644 --- a/frontend/build/webpack.config.js +++ b/frontend/build/webpack.config.js @@ -223,7 +223,7 @@ module.exports = (env) => { { loader: 'url-loader', options: { - limit: 10240, + limit: 24096, mimetype: 'application/font-woff', emitFile: false, name: 'Content/Fonts/[name].[ext]' @@ -233,11 +233,12 @@ module.exports = (env) => { }, { - test: /\.(ttf|eot|eot?#iefix|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, + test: /\.(ttf|eot|eot?#iefix|gif|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, use: [ { loader: 'file-loader', options: { + limit: 24096, emitFile: false, name: 'Content/Fonts/[name].[ext]' } diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 6ad82854c..881b2599c 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -7,6 +7,7 @@ import QueueConnector from 'Activity/Queue/QueueConnector'; import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import CollectionConnector from 'Collection/CollectionConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector'; @@ -72,6 +73,11 @@ function AppRoutes(props) { component={AddNewMovieConnector} /> + + + + + ); +} + +AddNewCollectionMovieModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddNewCollectionMovieModal; diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContent.css b/frontend/src/Collection/AddNewCollectionMovieModalContent.css new file mode 100644 index 000000000..9c42822be --- /dev/null +++ b/frontend/src/Collection/AddNewCollectionMovieModalContent.css @@ -0,0 +1,68 @@ +.container { + display: flex; +} + +.year { + margin-left: 5px; + color: $disabledColor; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; +} + +.labelIcon { + margin-left: 8px; +} + +.searchForMissingMovieLabelContainer { + display: flex; + margin-top: 2px; +} + +.searchForMissingMovieLabel { + margin-right: 8px; + font-weight: normal; +} + +.searchForMissingMovieContainer { + composes: container from '~Components/Form/CheckInput.css'; + + flex: 0 1 0; +} + +.searchForMissingMovieInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 0; +} + +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; +} + +.addButton { + @add-mixin truncate; + composes: button from '~Components/Link/SpinnerButton.css'; +} + +@media only screen and (max-width: $breakpointSmall) { + .modalFooter { + display: block; + text-align: center; + } + + .addButton { + margin-top: 10px; + } +} diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContent.js b/frontend/src/Collection/AddNewCollectionMovieModalContent.js new file mode 100644 index 000000000..2c8fb81c3 --- /dev/null +++ b/frontend/src/Collection/AddNewCollectionMovieModalContent.js @@ -0,0 +1,204 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import CheckInput from 'Components/Form/CheckInput'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes, kinds } from 'Helpers/Props'; +import MoviePoster from 'Movie/MoviePoster'; +import translate from 'Utilities/String/translate'; +import styles from './AddNewCollectionMovieModalContent.css'; + +class AddNewCollectionMovieModalContent extends Component { + + // + // Listeners + + onQualityProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) }); + } + + onAddMoviePress = () => { + this.props.onAddMoviePress(); + } + + // + // Render + + render() { + const { + title, + year, + overview, + images, + isAdding, + folder, + tags, + isSmallScreen, + isWindows, + onModalClose, + onInputChange, + rootFolderPath, + monitor, + qualityProfileId, + minimumAvailability, + searchForMovie + } = this.props; + + return ( + + + {title} + + { + !title.contains(year) && !!year && + ({year}) + } + + + +
+ { + !isSmallScreen && +
+ +
+ } + +
+
+ {overview} +
+ +
+ + {translate('RootFolder')} + + + + + + + {translate('Monitor')} + + + + + + + {translate('MinimumAvailability')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('Tags')} + + + +
+
+
+
+ + + + + + {translate('AddMovie')} + + +
+ ); + } +} + +AddNewCollectionMovieModalContent.propTypes = { + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + overview: PropTypes.string, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + isAdding: PropTypes.bool.isRequired, + addError: PropTypes.object, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + minimumAvailability: PropTypes.object.isRequired, + searchForMovie: PropTypes.object.isRequired, + folder: PropTypes.string.isRequired, + tags: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired, + onInputChange: PropTypes.func.isRequired, + onAddMoviePress: PropTypes.func.isRequired +}; + +export default AddNewCollectionMovieModalContent; diff --git a/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js b/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js new file mode 100644 index 000000000..cd8e2c017 --- /dev/null +++ b/frontend/src/Collection/AddNewCollectionMovieModalContentConnector.js @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { addMovie, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions'; +import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import AddNewMovieModalContent from './AddNewCollectionMovieModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieCollections, + createCollectionSelector(), + createDimensionsSelector(), + createSystemStatusSelector(), + (discoverMovieState, collection, dimensions, systemStatus) => { + const { + isAdding, + addError, + pendingChanges + } = discoverMovieState; + + const collectionDefaults = { + rootFolderPath: collection.rootFolderPath, + monitor: 'movieOnly', + qualityProfileId: collection.qualityProfileId, + minimumAvailability: collection.minimumAvailability, + searchForMovie: collection.searchOnAdd, + tags: [] + }; + + const { + settings, + validationErrors, + validationWarnings + } = selectSettings(collectionDefaults, pendingChanges, addError); + + return { + isAdding, + addError, + isSmallScreen: dimensions.isSmallScreen, + validationErrors, + validationWarnings, + isWindows: systemStatus.isWindows, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + addMovie, + setMovieCollectionValue +}; + +class AddNewCollectionMovieModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMovieCollectionValue({ name, value }); + } + + onAddMoviePress = () => { + const { + tmdbId, + title, + rootFolderPath, + monitor, + qualityProfileId, + minimumAvailability, + searchForMovie, + tags + } = this.props; + + this.props.addMovie({ + tmdbId, + title, + rootFolderPath: rootFolderPath.value, + monitor: monitor.value, + qualityProfileId: qualityProfileId.value, + minimumAvailability: minimumAvailability.value, + searchForMovie: searchForMovie.value, + tags: tags.value + }); + + this.props.onModalClose(true); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AddNewCollectionMovieModalContentConnector.propTypes = { + tmdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + rootFolderPath: PropTypes.object, + monitor: PropTypes.object.isRequired, + qualityProfileId: PropTypes.object, + minimumAvailability: PropTypes.object.isRequired, + searchForMovie: PropTypes.object.isRequired, + tags: PropTypes.object.isRequired, + onModalClose: PropTypes.func.isRequired, + addMovie: PropTypes.func.isRequired, + setMovieCollectionValue: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AddNewCollectionMovieModalContentConnector); diff --git a/frontend/src/Collection/Collection.js b/frontend/src/Collection/Collection.js new file mode 100644 index 000000000..8e24adb9c --- /dev/null +++ b/frontend/src/Collection/Collection.js @@ -0,0 +1,403 @@ +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 { align, icons, sortDirections } from 'Helpers/Props'; +import styles from 'Movie/Index/MovieIndex.css'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import CollectionFooter from './CollectionFooter'; +import CollectionFilterMenu from './Menus/CollectionFilterMenu'; +import CollectionSortMenu from './Menus/CollectionSortMenu'; +import NoCollection from './NoCollection'; +import CollectionOverviewsConnector from './Overview/CollectionOverviewsConnector'; +import CollectionOverviewOptionsModal from './Overview/Options/CollectionOverviewOptionsModal'; + +function getViewComponent(view) { + return CollectionOverviewsConnector; +} + +class Collection extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scroller: null, + jumpBarItems: { order: [] }, + jumpToCharacter: null, + isPosterOptionsModalOpen: false, + isOverviewOptionsModalOpen: false, + isConfirmSearchModalOpen: false, + searchType: null, + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {} + }; + } + + componentDidMount() { + this.setJumpBarItems(); + this.setSelectedState(); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection + } = this.props; + + if (sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection || + hasDifferentItemsOrOrder(prevProps.items, items) + ) { + this.setJumpBarItems(); + this.setSelectedState(); + } + + if (this.state.jumpToCharacter != null) { + this.setState({ jumpToCharacter: null }); + } + } + + // + // Control + + setScrollerRef = (ref) => { + this.setState({ scroller: ref }); + } + + getSelectedIds = () => { + if (this.state.allUnselected) { + return []; + } + return getSelectedIds(this.state.selectedState); + } + + setSelectedState() { + const { + items + } = this.props; + + const { + selectedState + } = this.state; + + const newSelectedState = {}; + + items.forEach((collection) => { + const isItemSelected = selectedState[collection.id]; + + if (isItemSelected) { + newSelectedState[collection.id] = isItemSelected; + } else { + newSelectedState[collection.id] = false; + } + }); + + const selectedCount = getSelectedIds(newSelectedState).length; + const newStateCount = Object.keys(newSelectedState).length; + let isAllSelected = false; + let isAllUnselected = false; + + if (selectedCount === 0) { + isAllUnselected = true; + } else if (selectedCount === newStateCount) { + isAllSelected = true; + } + + this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); + } + + 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 + + onOverviewOptionsPress = () => { + this.setState({ isOverviewOptionsModalOpen: true }); + } + + onOverviewOptionsModalClose = () => { + this.setState({ isOverviewOptionsModalOpen: false }); + } + + onJumpBarItemPress = (jumpToCharacter) => { + this.setState({ jumpToCharacter }); + } + + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectAllPress = () => { + this.onSelectAllChange({ value: !this.state.allSelected }); + } + + onRefreshMovieCollectionsPress = () => { + this.props.onRefreshMovieCollectionsPress(); + } + + onSelectedChange = ({ id, value, shiftKey = false }) => { + this.setState((state) => { + return toggleSelected(state, this.props.items, id, value, shiftKey, 'id'); + }); + } + + onUpdateSelectedPress = (changes) => { + this.props.onUpdateSelectedPress({ + collectionIds: this.getSelectedIds(), + ...changes + }); + } + + // + // Render + + render() { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + onSortSelect, + onFilterSelect, + onScroll, + isRefreshingCollections, + isSaving, + isAdding, + ...otherProps + } = this.props; + + const { + scroller, + jumpBarItems, + jumpToCharacter, + isOverviewOptionsModalOpen, + selectedState, + allSelected, + allUnselected + } = this.state; + + const selectedMovieIds = this.getSelectedIds(); + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && scroller); + const hasNoCollection = !totalItems; + + return ( + + + + + + + + + { + view === 'overview' ? + : + null + } + + { + (view === 'posters' || view === 'overview') && + + } + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
+ {translate('UnableToLoadCollections')} +
+ } + + { + isLoaded && +
+ +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.order.length && + + } +
+ + { + isLoaded && + + } + + +
+ ); + } +} + +Collection.propTypes = { + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + isAdding: PropTypes.bool.isRequired, + error: PropTypes.object, + totalItems: PropTypes.number.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + view: PropTypes.string.isRequired, + isRefreshingCollections: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired, + onUpdateSelectedPress: PropTypes.func.isRequired, + onRefreshMovieCollectionsPress: PropTypes.func.isRequired +}; + +export default Collection; diff --git a/frontend/src/Collection/CollectionConnector.js b/frontend/src/Collection/CollectionConnector.js new file mode 100644 index 000000000..3d3f10f29 --- /dev/null +++ b/frontend/src/Collection/CollectionConnector.js @@ -0,0 +1,108 @@ +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 { saveMovieCollections, setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions'; +import { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import scrollPositions from 'Store/scrollPositions'; +import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import Collection from './Collection'; + +function createMapStateToProps() { + return createSelector( + createCollectionClientSideCollectionItemsSelector('movieCollections'), + createCommandExecutingSelector(commandNames.REFRESH_COLLECTIONS), + createDimensionsSelector(), + ( + collections, + isRefreshingCollections, + dimensionsState + ) => { + return { + ...collections, + isRefreshingCollections, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + dispatchFetchRootFolders() { + dispatch(fetchRootFolders()); + }, + onUpdateSelectedPress(payload) { + dispatch(saveMovieCollections(payload)); + }, + onSortSelect(sortKey) { + dispatch(setMovieCollectionsSort({ sortKey })); + }, + onFilterSelect(selectedFilterKey) { + dispatch(setMovieCollectionsFilter({ selectedFilterKey })); + }, + onRefreshMovieCollectionsPress() { + dispatch(executeCommand({ + name: commandNames.REFRESH_COLLECTIONS + })); + } + }; +} + +class CollectionConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + registerPagePopulator(this.repopulate); + this.props.dispatchFetchRootFolders(); + } + + componentWillUnmount() { + unregisterPagePopulator(this.repopulate); + } + + // + // Listeners + + onScroll = ({ scrollTop }) => { + scrollPositions.movieCollections = scrollTop; + } + + onUpdateSelectedPress = (payload) => { + this.props.onUpdateSelectedPress(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CollectionConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + onUpdateSelectedPress: PropTypes.func.isRequired, + dispatchFetchRootFolders: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, createMapDispatchToProps)(CollectionConnector), + 'movieCollections' +); diff --git a/frontend/src/Collection/CollectionFilterModalConnector.js b/frontend/src/Collection/CollectionFilterModalConnector.js new file mode 100644 index 000000000..0fa897451 --- /dev/null +++ b/frontend/src/Collection/CollectionFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setMovieCollectionsFilter } from 'Store/Actions/movieCollectionActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieCollections.items, + (state) => state.movieCollections.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'movieCollections' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setMovieCollectionsFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Collection/CollectionFooter.css b/frontend/src/Collection/CollectionFooter.css new file mode 100644 index 000000000..c9cf4ce4c --- /dev/null +++ b/frontend/src/Collection/CollectionFooter.css @@ -0,0 +1,56 @@ +.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; +} + +.addSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.excludeSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-left: 25px; + 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/Collection/CollectionFooter.js b/frontend/src/Collection/CollectionFooter.js new file mode 100644 index 000000000..2df2d74f9 --- /dev/null +++ b/frontend/src/Collection/CollectionFooter.js @@ -0,0 +1,163 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectInput from 'Components/Form/SelectInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import CollectionFooterLabel from './CollectionFooterLabel'; +import styles from './CollectionFooter.css'; + +const NO_CHANGE = 'noChange'; + +class CollectionFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitor: NO_CHANGE, + monitored: NO_CHANGE, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + const newState = {}; + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + monitor: NO_CHANGE + }); + } + + if (!_.isEmpty(newState)) { + this.setState(newState); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + } + + onUpdateSelectedPress = () => { + const { + monitor, + monitored + } = this.state; + + const changes = {}; + + if (monitored !== NO_CHANGE) { + changes.monitored = monitored === 'monitored'; + } + + if (monitor !== NO_CHANGE) { + changes.monitor = monitor; + } + + this.props.onUpdateSelectedPress(changes); + } + + // + // Render + + render() { + const { + selectedIds, + isSaving + } = this.props; + + const { + monitored, + monitor + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: translate('No Change'), disabled: true }, + { key: 'monitored', value: translate('Monitored') }, + { key: 'unmonitored', value: translate('Unmonitored') } + ]; + + const selectedCount = selectedIds.length; + + return ( + +
+ + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + {translate('UpdateSelected')} + +
+
+
+
+
+ ); + } +} + +CollectionFooter.propTypes = { + selectedIds: PropTypes.arrayOf(PropTypes.number).isRequired, + isAdding: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + onUpdateSelectedPress: PropTypes.func.isRequired +}; + +export default CollectionFooter; diff --git a/frontend/src/Collection/CollectionFooterLabel.css b/frontend/src/Collection/CollectionFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Collection/CollectionFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Collection/CollectionFooterLabel.js b/frontend/src/Collection/CollectionFooterLabel.js new file mode 100644 index 000000000..6f8b578df --- /dev/null +++ b/frontend/src/Collection/CollectionFooterLabel.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 './CollectionFooterLabel.css'; + +function CollectionFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +CollectionFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +CollectionFooterLabel.defaultProps = { + className: styles.label +}; + +export default CollectionFooterLabel; diff --git a/frontend/src/Collection/CollectionItemConnector.js b/frontend/src/Collection/CollectionItemConnector.js new file mode 100644 index 000000000..d94fa6345 --- /dev/null +++ b/frontend/src/Collection/CollectionItemConnector.js @@ -0,0 +1,75 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; +import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; + +function createMapStateToProps() { + return createSelector( + createCollectionSelector(), + createAllMoviesSelector(), + ( + collection, + allMovies + ) => { + // 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 (!collection) { + return {}; + } + + let allGenres = []; + let libraryMovies = 0; + + collection.movies.forEach((movie) => { + allGenres = allGenres.concat(movie.genres); + + if (allMovies.find((libraryMovie) => libraryMovie.tmdbId === movie.tmdbId)) { + libraryMovies++; + } + }); + + return { + ...collection, + genres: Array.from(new Set(allGenres)).slice(0, 3), + missingMovies: collection.movies.length - libraryMovies + }; + } + ); +} + +class CollectionItemConnector extends Component { + + // + // Render + + render() { + const { + id, + component: ItemComponent, + ...otherProps + } = this.props; + + if (!id) { + return null; + } + + return ( + + ); + } +} + +CollectionItemConnector.propTypes = { + id: PropTypes.number, + component: PropTypes.elementType.isRequired +}; + +export default connect(createMapStateToProps)(CollectionItemConnector); diff --git a/frontend/src/Collection/Edit/EditCollectionModal.js b/frontend/src/Collection/Edit/EditCollectionModal.js new file mode 100644 index 000000000..1017aad0e --- /dev/null +++ b/frontend/src/Collection/Edit/EditCollectionModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditCollectionModalContentConnector from './EditCollectionModalContentConnector'; + +function EditCollectionModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditCollectionModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditCollectionModal; diff --git a/frontend/src/Collection/Edit/EditCollectionModalConnector.js b/frontend/src/Collection/Edit/EditCollectionModalConnector.js new file mode 100644 index 000000000..0ebc0006f --- /dev/null +++ b/frontend/src/Collection/Edit/EditCollectionModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditCollectionModal from './EditCollectionModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditCollectionModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'movieCollections' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditCollectionModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditCollectionModalConnector); diff --git a/frontend/src/Collection/Edit/EditCollectionModalContent.css b/frontend/src/Collection/Edit/EditCollectionModalContent.css new file mode 100644 index 000000000..9d99248e8 --- /dev/null +++ b/frontend/src/Collection/Edit/EditCollectionModalContent.css @@ -0,0 +1,17 @@ +.container { + display: flex; +} + +.poster { + flex: 0 0 170px; + margin-right: 20px; + height: 250px; +} + +.info { + flex-grow: 1; +} + +.overview { + margin-bottom: 30px; +} diff --git a/frontend/src/Collection/Edit/EditCollectionModalContent.js b/frontend/src/Collection/Edit/EditCollectionModalContent.js new file mode 100644 index 000000000..19bde104e --- /dev/null +++ b/frontend/src/Collection/Edit/EditCollectionModalContent.js @@ -0,0 +1,178 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import MoviePoster from 'Movie/MoviePoster'; +import translate from 'Utilities/String/translate'; +import styles from './EditCollectionModalContent.css'; + +class EditCollectionModalContent extends Component { + + // + // Listeners + + onSavePress = () => { + const { + onSavePress + } = this.props; + + onSavePress(false); + } + + // + // Render + + render() { + const { + title, + images, + overview, + item, + isSaving, + onInputChange, + onModalClose, + isSmallScreen, + ...otherProps + } = this.props; + + const { + monitored, + qualityProfileId, + minimumAvailability, + // Id, + rootFolderPath, + searchOnAdd + } = item; + + return ( + + + {translate('Edit')} - {title} + + + +
+ { + !isSmallScreen && +
+ +
+ } + +
+
+ {overview} +
+ +
+ + {translate('Monitored')} + + + + + + {translate('MinimumAvailability')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('Folder')} + + + + + + {translate('SearchOnAdd')} + + + +
+
+
+
+ + + + + + {translate('Save')} + + +
+ ); + } +} + +EditCollectionModalContent.propTypes = { + collectionId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + isPathChanging: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditCollectionModalContent; diff --git a/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js b/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js new file mode 100644 index 000000000..8e220aada --- /dev/null +++ b/frontend/src/Collection/Edit/EditCollectionModalContentConnector.js @@ -0,0 +1,119 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { saveMovieCollection, setMovieCollectionValue } from 'Store/Actions/movieCollectionActions'; +import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import selectSettings from 'Store/Selectors/selectSettings'; +import EditCollectionModalContent from './EditCollectionModalContent'; + +function createIsPathChangingSelector() { + return createSelector( + (state) => state.movieCollections.pendingChanges, + createCollectionSelector(), + (pendingChanges, collection) => { + const rootFolderPath = pendingChanges.rootFolderPath; + + if (rootFolderPath == null) { + return false; + } + + return collection.rootFolderPath !== rootFolderPath; + } + ); +} + +function createMapStateToProps() { + return createSelector( + (state) => state.movieCollections, + createCollectionSelector(), + createIsPathChangingSelector(), + createDimensionsSelector(), + (moviesState, collection, isPathChanging, dimensions) => { + const { + isSaving, + saveError, + pendingChanges + } = moviesState; + + const movieSettings = { + monitored: collection.monitored, + qualityProfileId: collection.qualityProfileId, + minimumAvailability: collection.minimumAvailability, + rootFolderPath: collection.rootFolderPath, + searchOnAdd: collection.searchOnAdd + }; + + const settings = selectSettings(movieSettings, pendingChanges, saveError); + + return { + title: collection.title, + images: collection.images, + overview: collection.overview, + isSaving, + saveError, + isPathChanging, + originalPath: collection.path, + item: settings.settings, + isSmallScreen: dimensions.isSmallScreen, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetMovieCollectionValue: setMovieCollectionValue, + dispatchSaveMovieCollection: saveMovieCollection +}; + +class EditCollectionModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetMovieCollectionValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveMovieCollection({ + id: this.props.collectionId + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditCollectionModalContentConnector.propTypes = { + collectionId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetMovieCollectionValue: PropTypes.func.isRequired, + dispatchSaveMovieCollection: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditCollectionModalContentConnector); diff --git a/frontend/src/Collection/Menus/CollectionFilterMenu.js b/frontend/src/Collection/Menus/CollectionFilterMenu.js new file mode 100644 index 000000000..652f2accf --- /dev/null +++ b/frontend/src/Collection/Menus/CollectionFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import CollectionFilterModalConnector from 'Collection/CollectionFilterModalConnector'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import { align } from 'Helpers/Props'; + +function CollectionFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +CollectionFilterMenu.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 +}; + +CollectionFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default CollectionFilterMenu; diff --git a/frontend/src/Collection/Menus/CollectionSortMenu.js b/frontend/src/Collection/Menus/CollectionSortMenu.js new file mode 100644 index 000000000..9738fa69f --- /dev/null +++ b/frontend/src/Collection/Menus/CollectionSortMenu.js @@ -0,0 +1,43 @@ +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 CollectionSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + {translate('Title')} + + + + ); +} + +CollectionSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default CollectionSortMenu; diff --git a/frontend/src/Collection/NoCollection.css b/frontend/src/Collection/NoCollection.css new file mode 100644 index 000000000..38a01f391 --- /dev/null +++ b/frontend/src/Collection/NoCollection.css @@ -0,0 +1,11 @@ +.message { + margin-top: 10px; + margin-bottom: 30px; + text-align: center; + font-size: 20px; +} + +.buttonContainer { + margin-top: 20px; + text-align: center; +} diff --git a/frontend/src/Collection/NoCollection.js b/frontend/src/Collection/NoCollection.js new file mode 100644 index 000000000..1e76fd014 --- /dev/null +++ b/frontend/src/Collection/NoCollection.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Button from 'Components/Link/Button'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './NoCollection.css'; + +function NoCollection(props) { + const { totalItems } = props; + + if (totalItems > 0) { + return ( +
+
+ {translate('AllCollectionsHiddenDueToFilter')} +
+
+ ); + } + + return ( +
+
+ {translate('NoCollections')} +
+ +
+ +
+ +
+ +
+
+ ); +} + +NoCollection.propTypes = { + totalItems: PropTypes.number.isRequired +}; + +export default NoCollection; diff --git a/frontend/src/Collection/Overview/CollectionMovie.css b/frontend/src/Collection/Overview/CollectionMovie.css new file mode 100644 index 000000000..1d9ee93fe --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionMovie.css @@ -0,0 +1,117 @@ +$hoverScale: 1.05; + +.content { + border-radius: 5px; + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 10px $black; + transition: all 200ms ease-in; + + .poster { + opacity: 0.5; + transition: opacity 100ms linear 100ms; + } + + .overlayTitle { + opacity: 1; + transition: opacity 100ms linear 100ms; + } + } +} + +.posterContainer { + position: relative; +} + +.poster { + position: relative; + display: block; + background-color: $defaultColor; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + display: flex; + justify-content: flex-end; + flex-direction: column; + width: 100%; + height: 100%; +} + +.overlayTitle { + padding: 5px; + color: $offWhite; + text-align: left; + font-weight: bold; + font-size: 15px; + opacity: 0; + transition: opacity 0; +} + +.title { + @add-mixin truncate; + + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: #707070; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: $radarrYellow; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} + +.editorSelect { + position: absolute; + top: 10px; + z-index: 3; +} + +.externalLinks { + margin-left: 0.5em; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + background-color: $defaultColor; +} + +.monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 25px; + color: $white; + + &:hover { + color: $iconButtonHoverLightColor; + } +} diff --git a/frontend/src/Collection/Overview/CollectionMovie.js b/frontend/src/Collection/Overview/CollectionMovie.js new file mode 100644 index 000000000..1628c7efc --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionMovie.js @@ -0,0 +1,191 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Link from 'Components/Link/Link'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector'; +import MovieIndexProgressBar from 'Movie/Index/ProgressBar/MovieIndexProgressBar'; +import MoviePoster from 'Movie/MoviePoster'; +import AddNewCollectionMovieModal from './../AddNewCollectionMovieModal'; +import styles from './CollectionMovie.css'; + +class CollectionMovie extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditMovieModalOpen: false, + isNewAddMovieModalOpen: false + }; + } + + // + // Listeners + + onEditMoviePress = () => { + this.setState({ isEditMovieModalOpen: true }); + } + + onEditMovieModalClose = () => { + this.setState({ isEditMovieModalOpen: false }); + } + + onAddMoviePress = () => { + this.setState({ isNewAddMovieModalOpen: true }); + } + + onAddMovieModalClose = () => { + this.setState({ isNewAddMovieModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + id, + title, + overview, + year, + tmdbId, + images, + monitored, + hasFile, + folder, + isAvailable, + isExistingMovie, + posterWidth, + posterHeight, + detailedProgressBar, + onMonitorTogglePress, + collectionId + } = this.props; + + const { + isEditMovieModalOpen, + isNewAddMovieModalOpen + } = this.state; + + const linkProps = id ? { to: `/movie/${tmdbId}` } : { onPress: this.onAddMoviePress }; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + borderRadius: '5px' + }; + + return ( +
+
+ { + isExistingMovie && +
+ +
+ } + + + + +
+
+ {title} +
+ + { + id && +
+ +
+ } +
+ +
+ + + + +
+ ); + } +} + +CollectionMovie.propTypes = { + id: PropTypes.number, + title: PropTypes.string.isRequired, + year: PropTypes.number.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool, + collectionId: PropTypes.number.isRequired, + hasFile: PropTypes.bool, + folder: PropTypes.string, + isAvailable: PropTypes.bool, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + posterWidth: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + isExistingMovie: PropTypes.bool, + tmdbId: PropTypes.number.isRequired, + imdbId: PropTypes.string, + youTubeTrailerId: PropTypes.string, + onMonitorTogglePress: PropTypes.func.isRequired +}; + +export default CollectionMovie; diff --git a/frontend/src/Collection/Overview/CollectionMovieConnector.js b/frontend/src/Collection/Overview/CollectionMovieConnector.js new file mode 100644 index 000000000..fa0518f1a --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionMovieConnector.js @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { toggleMovieMonitored } from 'Store/Actions/movieActions'; +import createCollectionExistingMovieSelector from 'Store/Selectors/createCollectionExistingMovieSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import CollectionMovie from './CollectionMovie'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + createCollectionExistingMovieSelector(), + (dimensions, existingMovie) => { + return { + isSmallScreen: dimensions.isSmallScreen, + isExistingMovie: !!existingMovie, + ...existingMovie + }; + } + ); +} + +const mapDispatchToProps = { + toggleMovieMonitored +}; + +class CollectionMovieConnector extends Component { + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleMovieMonitored({ + movieId: this.props.id, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CollectionMovieConnector.propTypes = { + id: PropTypes.number, + monitored: PropTypes.bool, + toggleMovieMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CollectionMovieConnector); diff --git a/frontend/src/Collection/Overview/CollectionOverview.css b/frontend/src/Collection/Overview/CollectionOverview.css new file mode 100644 index 000000000..fc7eef288 --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverview.css @@ -0,0 +1,132 @@ +$hoverScale: 1.05; + +.content { + display: flex; + flex-grow: 1; +} + +.editorSelect { + position: relative; + top: 0; + left: 5px; +} + +.titleRow { + position: relative; + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.toggleMonitoredContainer { + align-self: center; + margin-right: 10px; +} + +.titleContainer { + display: flex; + margin-bottom: 5px; +} + +.sliderContainer { + display: block; +} + +.moviesContainer { + margin-bottom: 5px; +} + +.movie { + padding: 7px; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.title { + @add-mixin truncate; + + font-weight: 300; + font-size: 30px; + line-height: 40px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; + margin-bottom: 5px; +} + +.defaults { + margin-bottom: 5px; + font-weight: 300; + font-size: 20px; +} + +.detailsLabel { + composes: label from '~Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.path, +.status, +.genres, +.qualityProfileName { + margin-left: 8px; + font-weight: 300; + font-size: 15px; +} + +.overview { + overflow: hidden; + min-height: 0; +} + +.monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 25px; + + &:hover { + color: $iconButtonHoverLightColor; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .navigationButtons { + display: none; + } + + .title { + @add-mixin truncate; + + font-weight: 300; + font-size: 22px; + } + + .toggleMonitoredContainer { + align-self: center; + margin-right: 5px; + } + + .monitorToggleButton { + composes: toggleButton from '~Components/MonitorToggleButton.css'; + + width: 20px; + + &:hover { + color: $iconButtonHoverLightColor; + } + } +} diff --git a/frontend/src/Collection/Overview/CollectionOverview.js b/frontend/src/Collection/Overview/CollectionOverview.js new file mode 100644 index 000000000..2300dfbfa --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverview.js @@ -0,0 +1,321 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Slider from 'react-slick'; +import TextTruncate from 'react-text-truncate'; +import EditCollectionModalConnector from 'Collection/Edit/EditCollectionModalConnector'; +import CheckInput from 'Components/Form/CheckInput'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import { icons, sizes } from 'Helpers/Props'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import translate from 'Utilities/String/translate'; +import CollectionMovieConnector from './CollectionMovieConnector'; +import styles from './CollectionOverview.css'; + +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height beased on line-height of 32 + bottom margin of 10. 19 + 5 for List Row +// Less side-effecty than using react-measure. +const titleRowHeight = 100; + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +class CollectionOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditCollectionModalOpen: false, + isNewAddMovieModalOpen: false + }; + } + + // + // Control + + setSliderRef = (ref) => { + this.setState({ slider: ref }); + } + + // + // Listeners + + onPress = () => { + this.setState({ isNewAddMovieModalOpen: true }); + } + + onEditCollectionPress = () => { + this.setState({ isEditCollectionModalOpen: true }); + } + + onEditCollectionModalClose = () => { + this.setState({ isEditCollectionModalOpen: false }); + } + + onAddMovieModalClose = () => { + this.setState({ isNewAddMovieModalOpen: false }); + } + + onChange = ({ value, shiftKey }) => { + const { + id, + onSelectedChange + } = this.props; + + onSelectedChange({ id, value, shiftKey }); + } + + // + // Render + + render() { + const { + monitored, + qualityProfileId, + rootFolderPath, + genres, + id, + title, + movies, + overview, + missingMovies, + posterHeight, + posterWidth, + rowHeight, + isSmallScreen, + isSelected, + onMonitorTogglePress + } = this.props; + + const { + showDetails, + showOverview, + detailedProgressBar + } = this.props.overviewOptions; + + const { + isEditCollectionModalOpen + } = this.state; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight - posterHeight; + + const sliderSettings = { + arrows: false, + dots: false, + infinite: false, + slidesToShow: 1, + slidesToScroll: 1, + variableWidth: true + }; + + return ( +
+
+
+ +
+
+ +
+
+
+ +
+
+ {title} +
+ + +
+ +
+ + + +
+
+ + { + showDetails && +
+ + + { + !isSmallScreen && + + } + + { + !isSmallScreen && + + } + + { + !isSmallScreen && + + } + +
+ } + + { + showOverview && +
+
+ +
+
+ } + +
+ + {movies.map((movie) => ( +
+ +
+ ))} +
+
+
+
+ + +
+ ); + } +} + +CollectionOverview.propTypes = { + id: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + qualityProfileId: PropTypes.number.isRequired, + minimumAvailability: PropTypes.string.isRequired, + searchOnAdd: PropTypes.bool.isRequired, + rootFolderPath: PropTypes.string.isRequired, + tmdbId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + movies: PropTypes.arrayOf(PropTypes.object).isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + missingMovies: PropTypes.number.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + rowHeight: PropTypes.number.isRequired, + posterHeight: PropTypes.number.isRequired, + posterWidth: PropTypes.number.isRequired, + overviewOptions: PropTypes.object.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + isSelected: PropTypes.bool, + onMonitorTogglePress: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default CollectionOverview; diff --git a/frontend/src/Collection/Overview/CollectionOverviewConnector.js b/frontend/src/Collection/Overview/CollectionOverviewConnector.js new file mode 100644 index 000000000..bc8590a05 --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverviewConnector.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { toggleCollectionMonitored } from 'Store/Actions/movieCollectionActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import CollectionOverview from './CollectionOverview'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + toggleCollectionMonitored +}; + +class CollectionOverviewConnector extends Component { + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleCollectionMonitored({ + collectionId: this.props.collectionId, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +CollectionOverviewConnector.propTypes = { + collectionId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + toggleCollectionMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CollectionOverviewConnector); diff --git a/frontend/src/Collection/Overview/CollectionOverviews.css b/frontend/src/Collection/Overview/CollectionOverviews.css new file mode 100644 index 000000000..a0cee09ff --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverviews.css @@ -0,0 +1,15 @@ +.grid { + flex: 1 0 auto; +} + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.externalLinks { + margin-right: 0.5em; +} diff --git a/frontend/src/Collection/Overview/CollectionOverviews.js b/frontend/src/Collection/Overview/CollectionOverviews.js new file mode 100644 index 000000000..8ab821c14 --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverviews.js @@ -0,0 +1,272 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import CollectionItemConnector from 'Collection/CollectionItemConnector'; +import Measure from 'Components/Measure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import CollectionOverviewConnector from './CollectionOverviewConnector'; +import styles from './CollectionOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); + +function calculatePosterWidth(posterSize, isSmallScreen) { + const maxiumPosterWidth = isSmallScreen ? 152 : 162; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); +} + +function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { + + const heights = [ + posterHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + return heights.reduce((acc, height) => acc + height + 80, 0); +} + +function calculatePosterHeight(posterWidth) { + return Math.ceil((250 / 170) * posterWidth); +} + +class CollectionOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 162, + posterHeight: 238, + rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}) + }; + + this._grid = null; + } + + componentDidUpdate(prevProps, prevState) { + const { + items, + sortKey, + overviewOptions, + jumpToCharacter, + scrollTop, + isSmallScreen + } = this.props; + + const { + width, + rowHeight, + scrollRestored + } = this.state; + + if (prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions) { + this.calculateGrid(this.state.width, isSmallScreen); + } + + if ( + this._grid && + (prevState.width !== width || + prevState.rowHeight !== rowHeight || + hasDifferentItemsOrOrder(prevProps.items, items) || + prevProps.overviewOptions !== overviewOptions)) { + // recomputeGridSize also forces Grid to discard its cache of rendered cells + this._grid.recomputeGridSize(); + } + + if (this._grid && scrollTop !== 0 && !scrollRestored) { + this.setState({ scrollRestored: true }); + this._grid.scrollToPosition({ scrollTop }); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (this._grid && index != null) { + + this._grid.scrollToCell({ + rowIndex: index, + columnIndex: 0 + }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + } + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const { + sortKey, + overviewOptions + } = this.props; + + const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); + const posterHeight = calculatePosterHeight(posterWidth); + const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); + + this.setState({ + width, + posterWidth, + posterHeight, + rowHeight + }); + } + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + overviewOptions, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen, + selectedState, + onSelectedChange + } = this.props; + + const { + posterWidth, + posterHeight, + rowHeight + } = this.state; + + const collection = items[rowIndex]; + + if (!collection) { + return null; + } + + return ( +
+ +
+ ); + } + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + } + + // + // Render + + render() { + const { + isSmallScreen, + scroller, + items, + selectedState + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, registerChild, onChildScroll, scrollTop }) => { + if (!height) { + return
; + } + + return ( +
+ +
+ ); + } + } + + + ); + } +} + +CollectionOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + overviewOptions: PropTypes.object.isRequired, + jumpToCharacter: PropTypes.string, + scrollTop: PropTypes.number.isRequired, + scroller: PropTypes.instanceOf(Element).isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + selectedState: PropTypes.object.isRequired, + onSelectedChange: PropTypes.func.isRequired +}; + +export default CollectionOverviews; diff --git a/frontend/src/Collection/Overview/CollectionOverviewsConnector.js b/frontend/src/Collection/Overview/CollectionOverviewsConnector.js new file mode 100644 index 000000000..05385eac1 --- /dev/null +++ b/frontend/src/Collection/Overview/CollectionOverviewsConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import CollectionOverviews from './CollectionOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieCollections.overviewOptions, + createUISettingsSelector(), + createDimensionsSelector(), + (overviewOptions, uiSettings, dimensions) => { + return { + overviewOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(CollectionOverviews); diff --git a/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModal.js b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModal.js new file mode 100644 index 000000000..b7773fbac --- /dev/null +++ b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CollectionOverviewOptionsModalContentConnector from './CollectionOverviewOptionsModalContentConnector'; + +function CollectionOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +CollectionOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CollectionOverviewOptionsModal; diff --git a/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContent.js b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContent.js new file mode 100644 index 000000000..c587b0048 --- /dev/null +++ b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContent.js @@ -0,0 +1,183 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; + +const posterSizeOptions = [ + { key: 'small', value: translate('Small') }, + { key: 'medium', value: translate('Medium') }, + { key: 'large', value: translate('Large') } +]; + +class CollectionOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showDetails: props.showDetails, + showOverview: props.showOverview + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showDetails, + showOverview + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showDetails !== prevProps.showDetails) { + state.showDetails = showDetails; + } + + if (showOverview !== prevProps.showOverview) { + state.showOverview = showOverview; + } + + if (!_.isEmpty(state)) { + this.setState(state); + } + } + + // + // Listeners + + onChangeOverviewOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOverviewOption({ [name]: value }); + }); + } + + onChangeOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangeOption({ + [name]: value + }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + size, + detailedProgressBar, + showDetails, + showOverview + } = this.state; + + return ( + + + Overview Options + + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowCollectionDetails')} + + + + + + {translate('ShowOverview')} + + + +
+
+ + + + +
+ ); + } +} + +CollectionOverviewOptionsModalContent.propTypes = { + detailedProgressBar: PropTypes.bool.isRequired, + size: PropTypes.string.isRequired, + showDetails: PropTypes.bool.isRequired, + showOverview: PropTypes.bool.isRequired, + onChangeOverviewOption: PropTypes.func.isRequired, + onChangeOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default CollectionOverviewOptionsModalContent; diff --git a/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContentConnector.js b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..aaa4ecb0b --- /dev/null +++ b/frontend/src/Collection/Overview/Options/CollectionOverviewOptionsModalContentConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setMovieCollectionsOption, setMovieCollectionsOverviewOption } from 'Store/Actions/movieCollectionActions'; +import CollectionOverviewOptionsModalContent from './CollectionOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.movieCollections, + (movieCollections) => { + return { + ...movieCollections.options, + ...movieCollections.overviewOptions + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setMovieCollectionsOverviewOption(payload)); + }, + onChangeOption(payload) { + dispatch(setMovieCollectionsOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(CollectionOverviewOptionsModalContent); diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index b91bf665c..bb48d3393 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -10,6 +10,7 @@ export const DOWNLOADED_MOVIES_SCAN = 'DownloadedMoviesScan'; export const INTERACTIVE_IMPORT = 'ManualImport'; export const MISSING_MOVIES_SEARCH = 'MissingMoviesSearch'; export const MOVE_MOVIE = 'MoveMovie'; +export const REFRESH_COLLECTIONS = 'RefreshCollections'; export const REFRESH_MOVIE = 'RefreshMovie'; export const RENAME_FILES = 'RenameFiles'; export const RENAME_MOVIE = 'RenameMovie'; diff --git a/frontend/src/Components/Form/MovieMonitoredSelectInput.js b/frontend/src/Components/Form/MovieMonitoredSelectInput.js index fcb2ff9ff..dcf1de0c9 100644 --- a/frontend/src/Components/Form/MovieMonitoredSelectInput.js +++ b/frontend/src/Components/Form/MovieMonitoredSelectInput.js @@ -1,15 +1,10 @@ import PropTypes from 'prop-types'; import React from 'react'; -import translate from 'Utilities/String/translate'; +import monitorOptions from 'Utilities/Movie/monitorOptions'; import SelectInput from './SelectInput'; -const monitorTypesOptions = [ - { key: 'true', value: translate('Yes') }, - { key: 'false', value: translate('No') } -]; - function MovieMonitoredSelectInput(props) { - const values = [...monitorTypesOptions]; + const values = [...monitorOptions]; const { includeNoChange, diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 4706c9dd2..4c25771d3 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchMovies } from 'Store/Actions/movieActions'; +import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions'; import { fetchImportLists, fetchIndexerFlags, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import { fetchTags } from 'Store/Actions/tagActions'; @@ -51,6 +52,7 @@ const selectIsPopulated = createSelector( (state) => state.settings.indexerFlags.isPopulated, (state) => state.settings.importLists.isPopulated, (state) => state.system.status.isPopulated, + (state) => state.movieCollections.isPopulated, ( customFiltersIsPopulated, tagsIsPopulated, @@ -59,7 +61,8 @@ const selectIsPopulated = createSelector( languagesIsPopulated, indexerFlagsIsPopulated, importListsIsPopulated, - systemStatusIsPopulated + systemStatusIsPopulated, + movieCollectionsIsPopulated ) => { return ( customFiltersIsPopulated && @@ -69,7 +72,8 @@ const selectIsPopulated = createSelector( languagesIsPopulated && indexerFlagsIsPopulated && importListsIsPopulated && - systemStatusIsPopulated + systemStatusIsPopulated && + movieCollectionsIsPopulated ); } ); @@ -83,6 +87,7 @@ const selectErrors = createSelector( (state) => state.settings.indexerFlags.error, (state) => state.settings.importLists.error, (state) => state.system.status.error, + (state) => state.movieCollections.error, ( customFiltersError, tagsError, @@ -91,7 +96,8 @@ const selectErrors = createSelector( languagesError, indexerFlagsError, importListsError, - systemStatusError + systemStatusError, + movieCollectionsError ) => { const hasError = !!( customFiltersError || @@ -101,7 +107,8 @@ const selectErrors = createSelector( languagesError || indexerFlagsError || importListsError || - systemStatusError + systemStatusError || + movieCollectionsError ); return { @@ -113,7 +120,8 @@ const selectErrors = createSelector( languagesError, indexerFlagsError, importListsError, - systemStatusError + systemStatusError, + movieCollectionsError }; } ); @@ -148,6 +156,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchMovies() { dispatch(fetchMovies()); }, + dispatchFetchMovieCollections() { + dispatch(fetchMovieCollections()); + }, dispatchFetchCustomFilters() { dispatch(fetchCustomFilters()); }, @@ -197,6 +208,7 @@ class PageConnector extends Component { componentDidMount() { if (!this.props.isPopulated) { this.props.dispatchFetchMovies(); + this.props.dispatchFetchMovieCollections(); this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchTags(); this.props.dispatchFetchQualityProfiles(); @@ -223,6 +235,7 @@ class PageConnector extends Component { isPopulated, hasError, dispatchFetchMovies, + dispatchFetchMovieCollections, dispatchFetchTags, dispatchFetchQualityProfiles, dispatchFetchLanguages, @@ -262,6 +275,7 @@ PageConnector.propTypes = { hasError: PropTypes.bool.isRequired, isSidebarVisible: PropTypes.bool.isRequired, dispatchFetchMovies: PropTypes.func.isRequired, + dispatchFetchMovieCollections: PropTypes.func.isRequired, dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchQualityProfiles: PropTypes.func.isRequired, diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 53c6ab327..257509ce5 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -33,6 +33,10 @@ const links = [ title: translate('ImportLibrary'), to: '/add/import' }, + { + title: translate('Collections'), + to: '/collections' + }, { title: translate('Discover'), to: '/add/discover' diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index 53a8daccd..d5308ca4d 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -203,6 +203,19 @@ class SignalRConnector extends Component { } }; + handleCollection = (body) => { + const action = body.action; + const section = 'movieCollections'; + + console.log(body); + + if (action === 'updated') { + this.props.dispatchUpdateItem({ section, ...body.resource }); + } else if (action === 'deleted') { + this.props.dispatchRemoveItem({ section, id: body.resource.id }); + } + } + handleQueue = () => { if (this.props.isQueuePopulated) { this.props.dispatchFetchQueue(); diff --git a/frontend/src/DiscoverMovie/Posters/DiscoverMoviePosterInfo.js b/frontend/src/DiscoverMovie/Posters/DiscoverMoviePosterInfo.js index a1b2b2b87..4e8d97ce2 100644 --- a/frontend/src/DiscoverMovie/Posters/DiscoverMoviePosterInfo.js +++ b/frontend/src/DiscoverMovie/Posters/DiscoverMoviePosterInfo.js @@ -129,7 +129,7 @@ DiscoverMoviePosterInfo.propTypes = { digitalRelease: PropTypes.string, physicalRelease: PropTypes.string, runtime: PropTypes.number, - ratings: PropTypes.arrayOf(PropTypes.object).isRequired, + ratings: PropTypes.object.isRequired, sortKey: PropTypes.string.isRequired, showRelativeDates: PropTypes.bool.isRequired, shortDateFormat: PropTypes.string.isRequired, diff --git a/frontend/src/DiscoverMovie/Table/DiscoverMovieRow.js b/frontend/src/DiscoverMovie/Table/DiscoverMovieRow.js index d4d30b6ba..9d6e12e4b 100644 --- a/frontend/src/DiscoverMovie/Table/DiscoverMovieRow.js +++ b/frontend/src/DiscoverMovie/Table/DiscoverMovieRow.js @@ -164,7 +164,7 @@ class DiscoverMovieRow extends Component { key={name} className={styles[name]} > - {collection ? collection.name : null } + {collection ? collection.title : null } ); } @@ -373,7 +373,7 @@ DiscoverMovieRow.propTypes = { digitalRelease: PropTypes.string, runtime: PropTypes.number, genres: PropTypes.arrayOf(PropTypes.string).isRequired, - ratings: PropTypes.arrayOf(PropTypes.object).isRequired, + ratings: PropTypes.object.isRequired, certification: PropTypes.string, collection: PropTypes.object, columns: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js index 59b9e8f94..9057bb7d2 100644 --- a/frontend/src/Movie/Details/MovieDetails.js +++ b/frontend/src/Movie/Details/MovieDetails.js @@ -39,7 +39,7 @@ import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; -import MovieCollectionConnector from './../MovieCollectionConnector'; +import MovieCollectionLabelConnector from './../MovieCollectionLabelConnector'; import MovieCastPostersConnector from './Credits/Cast/MovieCastPostersConnector'; import MovieCrewPostersConnector from './Credits/Crew/MovieCrewPostersConnector'; import MovieDetailsLinks from './MovieDetailsLinks'; @@ -269,7 +269,7 @@ class MovieDetails extends Component { monitored, studio, genres, - collection, + collectionId, overview, youTubeTrailerId, isAvailable, @@ -576,17 +576,15 @@ class MovieDetails extends Component { { - !!collection && + !!collectionId &&
-
@@ -802,7 +800,7 @@ MovieDetails.propTypes = { status: PropTypes.string.isRequired, studio: PropTypes.string, genres: PropTypes.arrayOf(PropTypes.string).isRequired, - collection: PropTypes.object, + collectionId: PropTypes.number, youTubeTrailerId: PropTypes.string, isAvailable: PropTypes.bool.isRequired, inCinemas: PropTypes.string, diff --git a/frontend/src/Movie/Index/MovieIndexItemConnector.js b/frontend/src/Movie/Index/MovieIndexItemConnector.js index 5f5710750..84c1f3034 100644 --- a/frontend/src/Movie/Index/MovieIndexItemConnector.js +++ b/frontend/src/Movie/Index/MovieIndexItemConnector.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import { executeCommand } from 'Store/Actions/commandActions'; +import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; import createMovieQualityProfileSelector from 'Store/Selectors/createMovieQualityProfileSelector'; import createMovieSelector from 'Store/Selectors/createMovieSelector'; @@ -29,12 +30,14 @@ function selectShowSearchAction() { function createMapStateToProps() { return createSelector( createMovieSelector(), + createCollectionSelector(), createMovieQualityProfileSelector(), selectShowSearchAction(), createExecutingCommandsSelector(), (state) => state.queue.details.items, ( movie, + collection, qualityProfile, showSearchAction, executingCommands, @@ -68,6 +71,7 @@ function createMapStateToProps() { return { ...movie, + collection, qualityProfile, showSearchAction, isRefreshingMovie, diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css index b98bb33d5..a2680291a 100644 --- a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css +++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.css @@ -7,6 +7,15 @@ transition: width 200ms ease; } +.progressRadius { + composes: container from '~Components/ProgressBar.css'; + + border-radius: 0 0 5px 5px; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + .progressBar { composes: progressBar from '~Components/ProgressBar.css'; diff --git a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js index 314fb618b..888b0c05d 100644 --- a/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js +++ b/frontend/src/Movie/Index/ProgressBar/MovieIndexProgressBar.js @@ -15,6 +15,7 @@ function MovieIndexProgressBar(props) { isAvailable, posterWidth, detailedProgressBar, + bottomRadius, queueStatus, queueState } = props; @@ -40,7 +41,7 @@ function MovieIndexProgressBar(props) { return ( - {collection ? collection.name : null } + {collection ? collection.title : null } ); } diff --git a/frontend/src/Movie/Index/Table/MovieIndexTable.js b/frontend/src/Movie/Index/Table/MovieIndexTable.js index 147ba9e0b..77e77ee8b 100644 --- a/frontend/src/Movie/Index/Table/MovieIndexTable.js +++ b/frontend/src/Movie/Index/Table/MovieIndexTable.js @@ -65,6 +65,7 @@ class MovieIndexTable extends Component { component={MovieIndexRow} columns={columns} movieId={movie.id} + collectionId={movie.collectionId} qualityProfileId={movie.qualityProfileId} isSelected={selectedState[movie.id]} onSelectedChange={onSelectedChange} diff --git a/frontend/src/Movie/MovieCollection.js b/frontend/src/Movie/MovieCollection.js deleted file mode 100644 index 86e560dfb..000000000 --- a/frontend/src/Movie/MovieCollection.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; -import EditImportListModalConnector from 'Settings/ImportLists/ImportLists/EditImportListModalConnector'; -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; - const importListId = collectionList ? collectionList.id : 0; - - 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/Movie/MovieCollectionConnector.js b/frontend/src/Movie/MovieCollectionConnector.js deleted file mode 100644 index e62a380de..000000000 --- a/frontend/src/Movie/MovieCollectionConnector.js +++ /dev/null @@ -1,90 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { saveImportList, selectImportListSchema, setImportListFieldValue, setImportListValue } from 'Store/Actions/settingsActions'; -import createMovieCollectionListSelector from 'Store/Selectors/createMovieCollectionListSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import MovieCollection from './MovieCollection'; - -function createMapStateToProps() { - return createSelector( - createMovieSelector(), - createMovieCollectionListSelector(), - (state) => state.settings.importLists, - (movie, collectionList, importLists) => { - const { - monitored, - qualityProfileId, - minimumAvailability - } = movie; - - return { - collectionList, - monitored, - qualityProfileId, - minimumAvailability, - isSaving: importLists.isSaving - }; - } - ); -} - -const mapDispatchToProps = { - selectImportListSchema, - setImportListFieldValue, - setImportListValue, - saveImportList -}; - -class MovieCollectionConnector extends Component { - - // - // Listeners - - onMonitorTogglePress = (monitored) => { - if (this.props.collectionList) { - this.props.setImportListValue({ name: 'enabled', value: monitored }); - this.props.setImportListValue({ name: 'enableAuto', value: monitored }); - this.props.saveImportList({ id: this.props.collectionList.id }); - } else { - this.props.selectImportListSchema({ implementation: 'TMDbCollectionImport', presetName: undefined }); - this.props.setImportListFieldValue({ name: 'collectionId', value: this.props.tmdbId.toString() }); - this.props.setImportListValue({ name: 'enabled', value: true }); - this.props.setImportListValue({ name: 'enableAuto', value: true }); - this.props.setImportListValue({ name: 'name', value: `${this.props.name} - ${this.props.tmdbId}` }); - this.props.setImportListValue({ name: 'qualityProfileId', value: this.props.qualityProfileId }); - this.props.setImportListValue({ name: 'monitored', value: this.props.monitored }); - this.props.setImportListValue({ name: 'minimumAvailability', value: this.props.minimumAvailability }); - } - }; - - // - // Render - - render() { - return ( - - ); - } -} - -MovieCollectionConnector.propTypes = { - tmdbId: PropTypes.number.isRequired, - movieId: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - collectionList: PropTypes.object, - monitored: PropTypes.bool.isRequired, - qualityProfileId: PropTypes.number.isRequired, - minimumAvailability: PropTypes.string.isRequired, - isSaving: PropTypes.bool.isRequired, - selectImportListSchema: PropTypes.func.isRequired, - setImportListFieldValue: PropTypes.func.isRequired, - setImportListValue: PropTypes.func.isRequired, - saveImportList: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(MovieCollectionConnector); diff --git a/frontend/src/Movie/MovieCollection.css b/frontend/src/Movie/MovieCollectionLabel.css similarity index 100% rename from frontend/src/Movie/MovieCollection.css rename to frontend/src/Movie/MovieCollectionLabel.css diff --git a/frontend/src/Movie/MovieCollectionLabel.js b/frontend/src/Movie/MovieCollectionLabel.js new file mode 100644 index 000000000..fb071f91c --- /dev/null +++ b/frontend/src/Movie/MovieCollectionLabel.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import styles from './MovieCollectionLabel.css'; + +class MovieCollectionLabel extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false + }; + } + + render() { + const { + title, + monitored, + onMonitorTogglePress + } = this.props; + + return ( +
+ + {title} +
+ ); + } +} + +MovieCollectionLabel.propTypes = { + title: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired +}; + +export default MovieCollectionLabel; diff --git a/frontend/src/Movie/MovieCollectionLabelConnector.js b/frontend/src/Movie/MovieCollectionLabelConnector.js new file mode 100644 index 000000000..6e553cc31 --- /dev/null +++ b/frontend/src/Movie/MovieCollectionLabelConnector.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { toggleCollectionMonitored } from 'Store/Actions/movieCollectionActions'; +import createCollectionSelector from 'Store/Selectors/createCollectionSelector'; +import MovieCollectionLabel from './MovieCollectionLabel'; + +function createMapStateToProps() { + return createSelector( + createCollectionSelector(), + (collection) => { + return { + ...collection + }; + } + ); +} + +const mapDispatchToProps = { + toggleCollectionMonitored +}; + +class MovieCollectionLabelConnector extends Component { + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleCollectionMonitored({ + collectionId: this.props.collectionId, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MovieCollectionLabelConnector.propTypes = { + collectionId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + toggleCollectionMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MovieCollectionLabelConnector); diff --git a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js index c3636aebb..2cc8eb7e5 100644 --- a/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js +++ b/frontend/src/Settings/ImportLists/ImportLists/EditImportListModalContent.js @@ -42,7 +42,7 @@ function EditImportListModalContent(props) { name, enabled, enableAuto, - shouldMonitor, + monitor, minimumAvailability, qualityProfileId, rootFolderPath, @@ -121,31 +121,28 @@ function EditImportListModalContent(props) { - {translate('AddMoviesMonitored')} + {translate('Monitor')} - { - shouldMonitor && - - {translate('SearchOnAdd')} + + {translate('SearchOnAdd')} - - - } + + {translate('MinimumAvailability')} diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js index 581882ffd..354b73e70 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfilesConnector.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions'; import { cloneQualityProfile, deleteQualityProfile, fetchQualityProfiles } from 'Store/Actions/settingsActions'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import sortByName from 'Utilities/Array/sortByName'; @@ -17,7 +18,8 @@ function createMapStateToProps() { const mapDispatchToProps = { dispatchFetchQualityProfiles: fetchQualityProfiles, dispatchDeleteQualityProfile: deleteQualityProfile, - dispatchCloneQualityProfile: cloneQualityProfile + dispatchCloneQualityProfile: cloneQualityProfile, + dispatchFetchMovieCollections: fetchMovieCollections }; class QualityProfilesConnector extends Component { @@ -27,6 +29,7 @@ class QualityProfilesConnector extends Component { componentDidMount() { this.props.dispatchFetchQualityProfiles(); + this.props.dispatchFetchMovieCollections(); } // @@ -57,7 +60,8 @@ class QualityProfilesConnector extends Component { QualityProfilesConnector.propTypes = { dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchDeleteQualityProfile: PropTypes.func.isRequired, - dispatchCloneQualityProfile: PropTypes.func.isRequired + dispatchCloneQualityProfile: PropTypes.func.isRequired, + dispatchFetchMovieCollections: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(QualityProfilesConnector); diff --git a/frontend/src/Store/Actions/addMovieActions.js b/frontend/src/Store/Actions/addMovieActions.js index a9d1cc893..c65698e99 100644 --- a/frontend/src/Store/Actions/addMovieActions.js +++ b/frontend/src/Store/Actions/addMovieActions.js @@ -30,7 +30,7 @@ export const defaultState = { defaults: { rootFolderPath: '', - monitor: 'true', + monitor: 'movieOnly', qualityProfileId: 0, minimumAvailability: 'announced', searchForMovie: true, diff --git a/frontend/src/Store/Actions/discoverMovieActions.js b/frontend/src/Store/Actions/discoverMovieActions.js index 3b2f85b9e..ab621653b 100644 --- a/frontend/src/Store/Actions/discoverMovieActions.js +++ b/frontend/src/Store/Actions/discoverMovieActions.js @@ -46,7 +46,7 @@ export const defaultState = { defaults: { rootFolderPath: '', - monitor: 'true', + monitor: 'movieOnly', qualityProfileId: 0, minimumAvailability: 'announced', searchForMovie: true, @@ -188,7 +188,7 @@ export const defaultState = { collection: function(item) { const { collection ={} } = item; - return collection.name; + return collection.title; }, studio: function(item) { diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index a9c3c5e35..09c373494 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -12,6 +12,7 @@ import * as importMovie from './importMovieActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as movies from './movieActions'; import * as movieBlocklist from './movieBlocklistActions'; +import * as movieCollections from './movieCollectionActions'; import * as movieCredits from './movieCreditsActions'; import * as movieFiles from './movieFileActions'; import * as movieHistory from './movieHistoryActions'; @@ -50,6 +51,7 @@ export default [ rootFolders, movies, movieBlocklist, + movieCollections, movieHistory, movieIndex, movieCredits, diff --git a/frontend/src/Store/Actions/movieActions.js b/frontend/src/Store/Actions/movieActions.js index 2e9183f89..d44c7e3b4 100644 --- a/frontend/src/Store/Actions/movieActions.js +++ b/frontend/src/Store/Actions/movieActions.js @@ -157,8 +157,6 @@ export const filterPredicates = { imdbRating: function(item, filterValue, type) { const predicate = filterTypePredicates[type]; - console.log(item.ratings); - const rating = item.ratings.imdb ? item.ratings.imdb.value : 0; return predicate(rating, filterValue); diff --git a/frontend/src/Store/Actions/movieCollectionActions.js b/frontend/src/Store/Actions/movieCollectionActions.js new file mode 100644 index 000000000..d9c836acb --- /dev/null +++ b/frontend/src/Store/Actions/movieCollectionActions.js @@ -0,0 +1,347 @@ +import _ from 'lodash'; +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 getNewMovie from 'Utilities/Movie/getNewMovie'; +import { set, update, updateItem } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; + +// +// Variables + +export const section = 'movieCollections'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + isSaving: false, + saveError: null, + isAdding: false, + addError: null, + sortKey: 'sortTitle', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'sortTitle', + secondarySortDirection: sortDirections.ASCENDING, + view: 'overview', + pendingChanges: {}, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showDetails: true, + showOverview: true + }, + + defaults: { + rootFolderPath: '', + monitor: 'movieOnly', + qualityProfileId: 0, + minimumAvailability: 'announced', + searchForMovie: true, + tags: [] + }, + + selectedFilterKey: 'all', + + filters: [ + { + key: 'all', + label: 'All', + filters: [] + } + ], + + filterPredicates: {}, + + filterBuilderProps: [ + { + name: 'title', + label: 'Title', + type: filterBuilderTypes.STRING + }, + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + } + ] +}; + +export const persistState = [ + 'movieCollections.defaults', + 'movieCollections.sortKey', + 'movieCollections.sortDirection', + 'movieCollections.selectedFilterKey', + 'movieCollections.customFilters', + 'movieCollections.options', + 'movieCollections.overviewOptions' +]; + +// +// Actions Types + +export const FETCH_MOVIE_COLLECTIONS = 'movieCollections/fetchMovieCollections'; +export const CLEAR_MOVIE_COLLECTIONS = 'movieCollections/clearMovieCollections'; +export const SAVE_MOVIE_COLLECTION = 'movieCollections/saveMovieCollection'; +export const SAVE_MOVIE_COLLECTIONS = 'movieCollections/saveMovieCollections'; +export const SET_MOVIE_COLLECTION_VALUE = 'movieCollections/setMovieCollectionValue'; + +export const ADD_MOVIE = 'movieCollections/addMovie'; + +export const TOGGLE_COLLECTION_MONITORED = 'movieCollections/toggleCollectionMonitored'; + +export const SET_MOVIE_COLLECTIONS_SORT = 'movieCollections/setMovieCollectionsSort'; +export const SET_MOVIE_COLLECTIONS_FILTER = 'movieCollections/setMovieCollectionsFilter'; +export const SET_MOVIE_COLLECTIONS_OPTION = 'movieCollections/setMovieCollectionsOption'; +export const SET_MOVIE_COLLECTIONS_OVERVIEW_OPTION = 'movieCollections/setMovieCollectionsOverviewOption'; + +// +// Action Creators + +export const fetchMovieCollections = createThunk(FETCH_MOVIE_COLLECTIONS); +export const clearMovieCollections = createAction(CLEAR_MOVIE_COLLECTIONS); +export const saveMovieCollection = createThunk(SAVE_MOVIE_COLLECTION); +export const saveMovieCollections = createThunk(SAVE_MOVIE_COLLECTIONS); + +export const addMovie = createThunk(ADD_MOVIE); + +export const toggleCollectionMonitored = createThunk(TOGGLE_COLLECTION_MONITORED); + +export const setMovieCollectionsSort = createAction(SET_MOVIE_COLLECTIONS_SORT); +export const setMovieCollectionsFilter = createAction(SET_MOVIE_COLLECTIONS_FILTER); +export const setMovieCollectionsOption = createAction(SET_MOVIE_COLLECTIONS_OPTION); +export const setMovieCollectionsOverviewOption = createAction(SET_MOVIE_COLLECTIONS_OVERVIEW_OPTION); + +export const setMovieCollectionValue = createAction(SET_MOVIE_COLLECTION_VALUE, (payload) => { + return { + section, + ...payload + }; +}); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [SAVE_MOVIE_COLLECTION]: createSaveProviderHandler(section, '/collection'), + [FETCH_MOVIE_COLLECTIONS]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = createAjaxRequest({ + url: '/collection', + data: payload + }).request; + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [ADD_MOVIE]: function(getState, payload, dispatch) { + dispatch(set({ section, isAdding: true })); + + const tmdbId = payload.tmdbId; + const title = payload.title; + + const newMovie = getNewMovie({ tmdbId, title }, payload); + newMovie.id = 0; + + const promise = createAjaxRequest({ + url: '/movie', + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(newMovie) + }).request; + + promise.done((data) => { + dispatch(batchActions([ + updateItem({ section: 'movies', ...data }), + + set({ + section, + isAdding: false, + isAdded: true, + addError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isAdding: false, + isAdded: false, + addError: xhr + })); + }); + }, + + [TOGGLE_COLLECTION_MONITORED]: (getState, payload, dispatch) => { + const { + collectionId: id, + monitored + } = payload; + + const collection = _.find(getState().movieCollections.items, { id }); + + dispatch(updateItem({ + id, + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: `/collection/${id}`, + method: 'PUT', + data: JSON.stringify({ + ...collection, + monitored + }), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(updateItem({ + id, + section, + isSaving: false, + monitored + })); + }); + + promise.fail((xhr) => { + dispatch(updateItem({ + id, + section, + isSaving: false + })); + }); + }, + + [SAVE_MOVIE_COLLECTIONS]: function(getState, payload, dispatch) { + const { + collectionIds, + monitored, + monitor + } = payload; + + const response = {}; + const collections = []; + + collectionIds.forEach((id) => { + const collectionToUpdate = { id }; + + if (payload.hasOwnProperty('monitored')) { + collectionToUpdate.monitored = monitored; + } + + collections.push(collectionToUpdate); + }); + + if (payload.hasOwnProperty('monitor')) { + response.monitorMovies = monitor === 'monitored'; + } + + response.collections = collections; + + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/collection', + method: 'PUT', + data: JSON.stringify(response), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(fetchMovieCollections()); + + dispatch(set({ + section, + isSaving: false, + saveError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_MOVIE_COLLECTIONS_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_MOVIE_COLLECTIONS_FILTER]: createSetClientSideCollectionFilterReducer(section), + [SET_MOVIE_COLLECTION_VALUE]: createSetSettingValueReducer(section), + + [SET_MOVIE_COLLECTIONS_OPTION]: function(state, { payload }) { + const movieCollectionsOptions = state.options; + + return { + ...state, + options: { + ...movieCollectionsOptions, + ...payload + } + }; + }, + + [SET_MOVIE_COLLECTIONS_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + }, + + [CLEAR_MOVIE_COLLECTIONS]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Migrators/migrateMonitorToEnum.js b/frontend/src/Store/Migrators/migrateMonitorToEnum.js new file mode 100644 index 000000000..11a8a1df8 --- /dev/null +++ b/frontend/src/Store/Migrators/migrateMonitorToEnum.js @@ -0,0 +1,26 @@ +import get from 'lodash'; + +export default function migrateMonitorToEnum(persistedState) { + const addMovie = get(persistedState, 'addMovie.defaults.monitor'); + const discoverMovie = get(persistedState, 'discoverMovie.defaults.monitor'); + + if (!addMovie && !discoverMovie) { + return; + } + + if (addMovie === true) { + persistedState.addMovie.defaults.monitor = 'movieOnly'; + } + + if (discoverMovie === true) { + persistedState.discoverMovie.defaults.monitor = 'movieOnly'; + } + + if (addMovie === false) { + persistedState.addMovie.defaults.minimumAvailability = 'none'; + } + + if (discoverMovie === false) { + persistedState.discoverMovie.defaults.minimumAvailability = 'none'; + } +} diff --git a/frontend/src/Store/Selectors/createCollectionClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createCollectionClientSideCollectionItemsSelector.js new file mode 100644 index 000000000..fd925a81c --- /dev/null +++ b/frontend/src/Store/Selectors/createCollectionClientSideCollectionItemsSelector.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('movieCollections', uiSection), + (movies) => { + const items = movies.items.map((s) => { + const { + id, + sortTitle + } = s; + + return { + id, + sortTitle + }; + }); + + return { + ...movies, + items + }; + } + ); +} + +function movieListEqual(a, b) { + return hasDifferentItemsOrOrder(a, b); +} + +const createMovieEqualSelector = createSelectorCreator( + defaultMemoize, + movieListEqual +); + +function createCollectionClientSideCollectionItemsSelector(uiSection) { + return createMovieEqualSelector( + createUnoptimizedSelector(uiSection), + (movies) => movies + ); +} + +export default createCollectionClientSideCollectionItemsSelector; diff --git a/frontend/src/Store/Selectors/createCollectionExistingMovieSelector.js b/frontend/src/Store/Selectors/createCollectionExistingMovieSelector.js new file mode 100644 index 000000000..024d4e939 --- /dev/null +++ b/frontend/src/Store/Selectors/createCollectionExistingMovieSelector.js @@ -0,0 +1,14 @@ +import { createSelector } from 'reselect'; +import createAllMoviesSelector from './createAllMoviesSelector'; + +function createCollectionExistingMovieSelector() { + return createSelector( + (state, { tmdbId }) => tmdbId, + createAllMoviesSelector(), + (tmdbId, allMovies) => { + return allMovies.find((movie) => movie.tmdbId === tmdbId); + } + ); +} + +export default createCollectionExistingMovieSelector; diff --git a/frontend/src/Store/Selectors/createCollectionSelector.js b/frontend/src/Store/Selectors/createCollectionSelector.js new file mode 100644 index 000000000..0b8717f96 --- /dev/null +++ b/frontend/src/Store/Selectors/createCollectionSelector.js @@ -0,0 +1,17 @@ +import { createSelector } from 'reselect'; + +function createCollectionSelector() { + return createSelector( + (state, { collectionId }) => collectionId, + (state) => state.movieCollections.itemMap, + (state) => state.movieCollections.items, + (collectionId, itemMap, allCollections) => { + if (allCollections && itemMap && collectionId in itemMap) { + return allCollections[itemMap[collectionId]]; + } + return undefined; + } + ); +} + +export default createCollectionSelector; diff --git a/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js index 755816c9d..336dd63d5 100644 --- a/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js +++ b/frontend/src/Store/Selectors/createMovieClientSideCollectionItemsSelector.js @@ -9,12 +9,14 @@ function createUnoptimizedSelector(uiSection) { const items = movies.items.map((s) => { const { id, - sortTitle + sortTitle, + collectionId } = s; return { id, - sortTitle + sortTitle, + collectionId }; }); diff --git a/frontend/src/Store/Selectors/createProfileInUseSelector.js b/frontend/src/Store/Selectors/createProfileInUseSelector.js index 7b2e4d169..a486facba 100644 --- a/frontend/src/Store/Selectors/createProfileInUseSelector.js +++ b/frontend/src/Store/Selectors/createProfileInUseSelector.js @@ -7,12 +7,13 @@ function createProfileInUseSelector(profileProp) { (state, { id }) => id, createAllMoviesSelector(), (state) => state.settings.importLists.items, - (id, movies, lists) => { + (state) => state.movieCollections.items, + (id, movies, lists, collections) => { if (!id) { return false; } - if (_.some(movies, { [profileProp]: id }) || _.some(lists, { [profileProp]: id })) { + if (_.some(movies, { [profileProp]: id }) || _.some(lists, { [profileProp]: id }) || _.some(collections, { [profileProp]: id })) { return true; } diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js index 99a558b4d..74983dcbc 100644 --- a/frontend/src/Store/scrollPositions.js +++ b/frontend/src/Store/scrollPositions.js @@ -1,6 +1,7 @@ const scrollPositions = { movieIndex: 0, - discoverMovie: 0 + discoverMovie: 0, + movieCollections: 0 }; export default scrollPositions; diff --git a/frontend/src/Utilities/Movie/getNewMovie.js b/frontend/src/Utilities/Movie/getNewMovie.js index e9dc84489..387fae616 100644 --- a/frontend/src/Utilities/Movie/getNewMovie.js +++ b/frontend/src/Utilities/Movie/getNewMovie.js @@ -10,11 +10,12 @@ function getNewMovie(movie, payload) { } = payload; const addOptions = { + monitor, searchForMovie }; movie.addOptions = addOptions; - movie.monitored = monitor === 'true'; + movie.monitored = monitor !== 'none'; movie.qualityProfileId = qualityProfileId; movie.minimumAvailability = minimumAvailability; movie.rootFolderPath = rootFolderPath; diff --git a/frontend/src/Utilities/Movie/monitorOptions.js b/frontend/src/Utilities/Movie/monitorOptions.js new file mode 100644 index 000000000..db2c519b8 --- /dev/null +++ b/frontend/src/Utilities/Movie/monitorOptions.js @@ -0,0 +1,9 @@ +import translate from 'Utilities/String/translate'; + +const monitorOptions = [ + { key: 'movieOnly', value: translate('MovieOnly') }, + { key: 'movieAndCollection', value: translate('MovieAndCollection') }, + { key: 'none', value: translate('None') } +]; + +export default monitorOptions; diff --git a/package.json b/package.json index 7e2caef39..8ba843755 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "react-document-title": "2.0.3", "react-dom": "17.0.2", "react-focus-lock": "2.5.0", + "react-slick": "0.28.1", + "slick-carousel": "1.8.1", "react-google-recaptcha": "2.1.0", "react-lazyload": "3.2.0", "react-measure": "1.4.7", diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/208_collectionsFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/208_collectionsFixture.cs new file mode 100644 index 000000000..0b9f2c898 --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/208_collectionsFixture.cs @@ -0,0 +1,273 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class collectionsFixture : MigrationTest + { + [Test] + public void should_add_collection_from_movie_and_link_back_to_movie() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Movies").Row(new + { + Monitored = true, + Title = "Title", + CleanTitle = "CleanTitle", + Status = 3, + MinimumAvailability = 4, + Images = new[] { new { CoverType = "Poster" } }.ToJson(), + Recommendations = new[] { 1 }.ToJson(), + Runtime = 90, + OriginalLanguage = 1, + ProfileId = 1, + MovieFileId = 0, + Path = string.Format("/Movies/{0}", "Title"), + TitleSlug = 123456, + TmdbId = 132456, + Added = DateTime.UtcNow, + Collection = new { Name = "Some Collection", TmdbId = 11 }.ToJson(), + LastInfoSync = DateTime.UtcNow, + }); + }); + + var collections = db.Query("SELECT Id, Title, TmdbId, Monitored FROM Collections"); + + collections.Should().HaveCount(1); + collections.First().TmdbId.Should().Be(11); + collections.First().Title.Should().Be("Some Collection"); + collections.First().Monitored.Should().BeFalse(); + + var movies = db.Query("SELECT Id, CollectionId FROM Movies"); + + movies.Should().HaveCount(1); + movies.First().CollectionId.Should().Be(collections.First().Id); + } + + [Test] + public void should_not_duplicate_collection() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Movies").Row(new + { + Monitored = true, + Title = "Title", + CleanTitle = "CleanTitle", + Status = 3, + MinimumAvailability = 4, + Images = new[] { new { CoverType = "Poster" } }.ToJson(), + Recommendations = new[] { 1 }.ToJson(), + Runtime = 90, + OriginalLanguage = 1, + ProfileId = 1, + MovieFileId = 0, + Path = string.Format("/Movies/{0}", "Title"), + TitleSlug = 123456, + TmdbId = 132456, + Added = DateTime.UtcNow, + Collection = new { Name = "Some Collection", TmdbId = 11 }.ToJson(), + LastInfoSync = DateTime.UtcNow, + }); + + c.Insert.IntoTable("Movies").Row(new + { + Monitored = true, + Title = "Title 2", + CleanTitle = "CleanTitle2", + Status = 3, + MinimumAvailability = 4, + Images = new[] { new { CoverType = "Poster" } }.ToJson(), + Recommendations = new[] { 1 }.ToJson(), + Runtime = 90, + OriginalLanguage = 1, + ProfileId = 1, + MovieFileId = 0, + Path = string.Format("/Movies/{0}", "Title 2"), + TitleSlug = 123457, + TmdbId = 132457, + Added = DateTime.UtcNow, + Collection = new { Name = "Some Collection", TmdbId = 11 }.ToJson(), + LastInfoSync = DateTime.UtcNow, + }); + }); + + var collections = db.Query("SELECT Id, Title, TmdbId, Monitored FROM Collections"); + + collections.Should().HaveCount(1); + collections.First().TmdbId.Should().Be(11); + collections.First().Title.Should().Be("Some Collection"); + collections.First().Monitored.Should().BeFalse(); + } + + [Test] + public void should_migrate_true_monitor_setting_on_lists() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("ImportLists").Row(new + { + Enabled = 1, + EnableAuto = 1, + RootFolderPath = "D:\\Movies", + ProfileId = 1, + MinimumAvailability = 4, + ShouldMonitor = true, + Name = "IMDB List", + Implementation = "RadarrLists", + Settings = new RadarrListSettings169 + { + APIURL = "https://api.radarr.video/v2", + Path = "/imdb/list?listId=ls000199717", + }.ToJson(), + ConfigContract = "RadarrSettings" + }); + }); + + var items = db.Query("SELECT Id, Monitor FROM ImportLists"); + + items.Should().HaveCount(1); + items.First().Monitor.Should().Be(0); + } + + [Test] + public void should_migrate_false_monitor_setting_on_lists() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("ImportLists").Row(new + { + Enabled = 1, + EnableAuto = 1, + RootFolderPath = "D:\\Movies", + ProfileId = 1, + MinimumAvailability = 4, + ShouldMonitor = false, + Name = "IMDB List", + Implementation = "RadarrLists", + Settings = new RadarrListSettings169 + { + APIURL = "https://api.radarr.video/v2", + Path = "/imdb/list?listId=ls000199717", + }.ToJson(), + ConfigContract = "RadarrSettings" + }); + }); + + var items = db.Query("SELECT Id, Monitor FROM ImportLists"); + + items.Should().HaveCount(1); + items.First().Monitor.Should().Be(2); + } + + [Test] + public void should_purge_tmdb_collection_lists() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("ImportLists").Row(new + { + Enabled = 1, + EnableAuto = 1, + RootFolderPath = "D:\\Movies", + ProfileId = 1, + MinimumAvailability = 4, + ShouldMonitor = false, + Name = "IMDB List", + Implementation = "TMDbCollectionImport", + Settings = new TmdbCollectionListSettings206 + { + CollectionId = "11" + }.ToJson(), + ConfigContract = "TMDbCollectionSettings" + }); + }); + + var items = db.Query("SELECT Id, Monitor FROM ImportLists"); + + items.Should().HaveCount(0); + } + + [Test] + public void should_monitor_new_collection_if_list_enabled_and_auto() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Movies").Row(new + { + Monitored = true, + Title = "Title", + CleanTitle = "CleanTitle", + Status = 3, + MinimumAvailability = 4, + Images = new[] { new { CoverType = "Poster" } }.ToJson(), + Recommendations = new[] { 1 }.ToJson(), + Runtime = 90, + OriginalLanguage = 1, + ProfileId = 1, + MovieFileId = 0, + Path = string.Format("/Movies/{0}", "Title"), + TitleSlug = 123456, + TmdbId = 132456, + Added = DateTime.UtcNow, + Collection = new { Name = "Some Collection", TmdbId = 11 }.ToJson(), + LastInfoSync = DateTime.UtcNow, + }); + + c.Insert.IntoTable("ImportLists").Row(new + { + Enabled = 1, + EnableAuto = 1, + RootFolderPath = "D:\\Movies", + ProfileId = 1, + MinimumAvailability = 4, + ShouldMonitor = false, + Name = "IMDB List", + Implementation = "TMDbCollectionImport", + Settings = new TmdbCollectionListSettings206 + { + CollectionId = "11" + }.ToJson(), + ConfigContract = "TMDbCollectionSettings" + }); + }); + + var items = db.Query("SELECT Id, Monitored FROM Collections"); + + items.Should().HaveCount(1); + items.First().Monitored.Should().BeTrue(); + } + } + + public class Collection207 + { + public int Id { get; set; } + public int TmdbId { get; set; } + public string Title { get; set; } + public bool Monitored { get; set; } + } + + public class Movie207 + { + public int Id { get; set; } + public int CollectionId { get; set; } + } + + public class ListDefinition207 + { + public int Id { get; set; } + public int Monitor { get; set; } + } + + public class TmdbCollectionListSettings206 + { + public string CollectionId { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedCollectionsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedCollectionsFixture.cs new file mode 100644 index 000000000..2839382a7 --- /dev/null +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupOrphanedCollectionsFixture.cs @@ -0,0 +1,45 @@ +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Housekeeping.Housekeepers; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Housekeeping.Housekeepers +{ + [TestFixture] + public class CleanupOrphanedCollectionsFixture : DbTest + { + [Test] + public void should_delete_orphaned_collection_item() + { + var collection = Builder.CreateNew() + .With(h => h.Id = 3) + .With(h => h.Title = "Some Credit") + .BuildNew(); + + Db.Insert(collection); + Subject.Clean(); + AllStoredModels.Should().BeEmpty(); + } + + [Test] + public void should_not_delete_unorphaned_credit_items() + { + var collection = Builder.CreateNew() + .With(h => h.Id = 3) + .With(h => h.Title = "Some Credit") + .BuildNew(); + + Db.Insert(collection); + + var movie = Builder.CreateNew().With(m => m.CollectionId = collection.Id).BuildNew(); + + Db.Insert(movie); + + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } + } +} diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index f24756c5b..7a7250503 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.Languages; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Organizer; using NzbDrone.Core.Qualities; @@ -226,7 +227,7 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests public void should_replace_movie_collection() { _namingConfig.StandardMovieFormat = "{Movie Collection}"; - _movie.MovieMetadata.Value.Collection = new MovieCollection { Name = "South Part Collection" }; + _movie.MovieMetadata.Value.Collection = new MovieCollection { Title = "South Part Collection" }; Subject.BuildFileName(_movie, _movieFile) .Should().Be("South Part Collection"); diff --git a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs index b4739718b..583730671 100644 --- a/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Profiles/ProfileServiceFixture.cs @@ -9,6 +9,7 @@ using NzbDrone.Core.ImportLists; using NzbDrone.Core.Languages; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Profiles; using NzbDrone.Core.Test.CustomFormats; using NzbDrone.Core.Test.Framework; @@ -90,6 +91,33 @@ namespace NzbDrone.Core.Test.Profiles Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); } + [Test] + public void should_not_be_able_to_delete_profile_if_assigned_to_collection() + { + var movieList = Builder.CreateListOfSize(3) + .All() + .With(c => c.ProfileId = 1) + .Build().ToList(); + + var importList = Builder.CreateListOfSize(3) + .Random(1) + .With(c => c.ProfileId = 1) + .Build().ToList(); + + var collectionList = Builder.CreateListOfSize(3) + .All() + .With(c => c.QualityProfileId = 2) + .Build().ToList(); + + Mocker.GetMock().Setup(c => c.GetAllMovies()).Returns(movieList); + Mocker.GetMock().Setup(c => c.All()).Returns(importList); + Mocker.GetMock().Setup(c => c.GetAllCollections()).Returns(collectionList); + + Assert.Throws(() => Subject.Delete(2)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + } + [Test] public void should_delete_profile_if_not_assigned_to_movie_or_list() { @@ -103,8 +131,14 @@ namespace NzbDrone.Core.Test.Profiles .With(c => c.ProfileId = 2) .Build().ToList(); + var collectionList = Builder.CreateListOfSize(3) + .All() + .With(c => c.QualityProfileId = 2) + .Build().ToList(); + Mocker.GetMock().Setup(c => c.GetAllMovies()).Returns(movieList); Mocker.GetMock().Setup(c => c.All()).Returns(importList); + Mocker.GetMock().Setup(c => c.GetAllCollections()).Returns(collectionList); Subject.Delete(1); diff --git a/src/NzbDrone.Core/Datastore/LazyLoaded.cs b/src/NzbDrone.Core/Datastore/LazyLoaded.cs index 91ff44c82..288726efb 100644 --- a/src/NzbDrone.Core/Datastore/LazyLoaded.cs +++ b/src/NzbDrone.Core/Datastore/LazyLoaded.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Text.Json.Serialization; using NLog; using NzbDrone.Common.Instrumentation; @@ -15,6 +16,7 @@ namespace NzbDrone.Core.Datastore /// Allows a field to be lazy loaded. /// /// + [JsonConverter(typeof(LazyLoadedConverterFactory))] public class LazyLoaded : ILazyLoaded { protected TChild _value; @@ -62,11 +64,6 @@ namespace NzbDrone.Core.Datastore { return MemberwiseClone(); } - - public bool ShouldSerializeValue() - { - return IsLoaded; - } } /// diff --git a/src/NzbDrone.Core/Datastore/LazyLoadedConverterFactory.cs b/src/NzbDrone.Core/Datastore/LazyLoadedConverterFactory.cs new file mode 100644 index 000000000..4e02ef794 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/LazyLoadedConverterFactory.cs @@ -0,0 +1,90 @@ +using System; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace NzbDrone.Core.Datastore +{ + public class LazyLoadedConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + if (!typeToConvert.IsGenericType) + { + return false; + } + + return typeToConvert.GetGenericTypeDefinition() == typeof(LazyLoaded<>); + } + + public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options) + { + var childType = type.GetGenericArguments()[0]; + + return (JsonConverter)Activator.CreateInstance( + typeof(LazyLoadedConverter<>).MakeGenericType(childType), + BindingFlags.Instance | BindingFlags.Public, + binder: null, + args: new object[] { options }, + culture: null); + } + + private class LazyLoadedConverter : JsonConverter> + { + private readonly JsonConverter _childConverter; + private readonly Type _childType; + + public LazyLoadedConverter(JsonSerializerOptions options) + { + // For performance, use the existing converter if available. + _childConverter = (JsonConverter)options + .GetConverter(typeof(TChild)); + + // Cache the type. + _childType = typeof(TChild); + } + + public override LazyLoaded Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + TChild value; + if (_childConverter != null) + { + reader.Read(); + value = _childConverter.Read(ref reader, _childType, options); + } + else + { + value = JsonSerializer.Deserialize(ref reader, options); + } + + if (value != null) + { + return new LazyLoaded(value); + } + else + { + return null; + } + } + + public override void Write(Utf8JsonWriter writer, LazyLoaded value, JsonSerializerOptions options) + { + if (value.IsLoaded) + { + if (_childConverter != null) + { + _childConverter.Write(writer, value.Value, options); + } + else + { + JsonSerializer.Serialize(writer, value.Value, options); + } + } + else + { + writer.WriteNullValue(); + } + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/208_collections.cs b/src/NzbDrone.Core/Datastore/Migration/208_collections.cs new file mode 100644 index 000000000..a697d8fd5 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/208_collections.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using Dapper; +using FluentMigrator; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration.Framework; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(208)] + public class collections : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("Collections") + .WithColumn("TmdbId").AsInt32().Unique() + .WithColumn("QualityProfileId").AsInt32() + .WithColumn("RootFolderPath").AsString() + .WithColumn("MinimumAvailability").AsInt32() + .WithColumn("SearchOnAdd").AsBoolean() + .WithColumn("Title").AsString() + .WithColumn("SortTitle").AsString().Nullable() + .WithColumn("CleanTitle").AsString() + .WithColumn("Overview").AsString().Nullable() + .WithColumn("Images").AsString().WithDefaultValue("[]") + .WithColumn("Monitored").AsBoolean().WithDefaultValue(false) + .WithColumn("LastInfoSync").AsDateTime().Nullable() + .WithColumn("Added").AsDateTime().Nullable() + .WithColumn("Movies").AsString().WithDefaultValue("[]"); + + Alter.Table("Movies").AddColumn("CollectionId").AsInt32().Nullable(); + Alter.Table("ImportLists").AddColumn("Monitor").AsInt32().Nullable(); + + Execute.WithConnection(MigrateCollections); + Execute.WithConnection(MigrateCollectionMonitorStatus); + Execute.WithConnection(MapCollections); + Execute.WithConnection(MigrateListMonitor); + + Alter.Table("ImportLists").AlterColumn("Monitor").AsInt32().NotNullable(); + + Delete.Column("ShouldMonitor").FromTable("ImportLists"); + Delete.FromTable("ImportLists").Row(new { Implementation = "TMDbCollectionImport" }); + Delete.Column("Collection").FromTable("Movies"); + } + + private void MigrateCollections(IDbConnection conn, IDbTransaction tran) + { + var rootPaths = new List(); + using (var getRootFolders = conn.CreateCommand()) + { + getRootFolders.Transaction = tran; + getRootFolders.CommandText = @"SELECT ""Path"" FROM ""RootFolders"""; + + using (var definitionsReader = getRootFolders.ExecuteReader()) + { + while (definitionsReader.Read()) + { + string path = definitionsReader.GetString(0); + rootPaths.Add(path); + } + } + } + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Collection\", \"ProfileId\", \"MinimumAvailability\", \"Path\" FROM \"Movies\" WHERE \"Collection\" IS NOT NULL GROUP BY \"Collection\""; + + var addedCollections = new List(); + var added = DateTime.UtcNow; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var collection = reader.GetString(0); + var qualityProfileId = reader.GetInt32(1); + var minimumAvailability = reader.GetInt32(2); + var moviePath = reader.GetString(3); + var data = STJson.Deserialize(collection); + + if (addedCollections.Contains(data.TmdbId)) + { + continue; + } + + var rootFolderPath = rootPaths.Where(r => r.IsParentPath(moviePath)) + .OrderByDescending(r => r.Length) + .FirstOrDefault(); + + if (rootFolderPath == null) + { + rootFolderPath = rootPaths.FirstOrDefault(); + } + + if (rootFolderPath == null) + { + rootFolderPath = moviePath.GetParentPath(); + } + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = @"INSERT INTO ""Collections"" (""TmdbId"", ""Title"", ""CleanTitle"", ""SortTitle"", ""Added"", ""QualityProfileId"", ""RootFolderPath"", ""SearchOnAdd"", ""MinimumAvailability"") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + updateCmd.AddParameter(data.TmdbId); + updateCmd.AddParameter(data.Name); + updateCmd.AddParameter(data.Name.CleanMovieTitle()); + updateCmd.AddParameter(Parser.Parser.NormalizeTitle(data.Name)); + updateCmd.AddParameter(added); + updateCmd.AddParameter(qualityProfileId); + updateCmd.AddParameter(rootFolderPath); + updateCmd.AddParameter(true); + updateCmd.AddParameter(minimumAvailability); + + updateCmd.ExecuteNonQuery(); + } + + addedCollections.Add(data.TmdbId); + } + } + } + } + + private void MigrateCollectionMonitorStatus(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Enabled\", \"EnableAuto\", \"Settings\", \"ShouldMonitor\", \"Id\" FROM \"ImportLists\" WHERE \"Implementation\" = \"TMDbCollectionImport\""; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var enabled = reader.GetBoolean(0); + var enabledAutoAdd = reader.GetBoolean(1); + var settings = reader.GetString(2); + var shouldMonitor = reader.GetBoolean(3); + var listId = reader.GetInt32(4); + var data = STJson.Deserialize(settings); + + if (!enabled || !enabledAutoAdd || !int.TryParse(data.CollectionId, out var collectionId)) + { + continue; + } + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = @"UPDATE ""Collections"" SET ""Monitored"" = ? WHERE ""TmdbId"" = ?"; + updateCmd.AddParameter(true); + updateCmd.AddParameter(collectionId); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + + private void MigrateListMonitor(IDbConnection conn, IDbTransaction tran) + { + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"ShouldMonitor\", \"Id\" FROM \"ImportLists\""; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var shouldMonitor = reader.GetBoolean(0); + var listId = reader.GetInt32(1); + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = @"UPDATE ""ImportLists"" SET ""Monitor"" = ? WHERE ""Id"" = ?"; + updateCmd.AddParameter(shouldMonitor ? 0 : 2); + updateCmd.AddParameter(listId); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + + private void MapCollections(IDbConnection conn, IDbTransaction tran) + { + var collections = new List(); + using (var getCollections = conn.CreateCommand()) + { + getCollections.Transaction = tran; + getCollections.CommandText = @"SELECT ""Id"", ""TmdbId"" FROM ""Collections"""; + + using (var definitionsReader = getCollections.ExecuteReader()) + { + while (definitionsReader.Read()) + { + int id = definitionsReader.GetInt32(0); + int tmdbId = definitionsReader.GetInt32(1); + collections.Add(new MovieCollection207 { Id = id, TmdbId = tmdbId }); + } + } + } + + using (var cmd = conn.CreateCommand()) + { + cmd.Transaction = tran; + cmd.CommandText = "SELECT \"Id\", \"Collection\" FROM \"Movies\" WHERE \"Collection\" IS NOT NULL"; + + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var id = reader.GetInt32(0); + var collection = reader.GetString(1); + var data = STJson.Deserialize(collection); + + var collectionId = collections.SingleOrDefault(x => x.TmdbId == data.TmdbId).Id; + + using (var updateCmd = conn.CreateCommand()) + { + updateCmd.Transaction = tran; + updateCmd.CommandText = @"UPDATE ""Movies"" SET ""CollectionId"" = ? WHERE ""Id"" = ?"; + updateCmd.AddParameter(collectionId); + updateCmd.AddParameter(id); + + updateCmd.ExecuteNonQuery(); + } + } + } + } + } + + private class MovieCollection206 + { + public string Name { get; set; } + public int TmdbId { get; set; } + } + + private class MovieCollection207 + { + public int Id { get; set; } + public string Title { get; set; } + public int TmdbId { get; set; } + } + + private class TmdbCollectionSettings206 + { + public string CollectionId { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index cc609e986..357ce19c5 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -28,6 +28,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Movies; using NzbDrone.Core.Movies.AlternativeTitles; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Credits; using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Notifications; @@ -168,6 +169,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity("MovieMetadata").RegisterModel() .Ignore(s => s.Translations); + + Mapper.Entity("Collections").RegisterModel(); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs index 3ae3fa7b1..e6d692c58 100644 --- a/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs +++ b/src/NzbDrone.Core/Download/Clients/Flood/Flood.cs @@ -52,7 +52,7 @@ namespace NzbDrone.Core.Download.Clients.Flood switch (additionalTag) { case (int)AdditionalTags.Collection: - result.Add(remoteMovie.Movie.MovieMetadata.Value.Collection.Name); + result.Add(remoteMovie.Movie.MovieMetadata.Value.Collection.Value.Title); break; case (int)AdditionalTags.Quality: result.Add(remoteMovie.ParsedMovieInfo.Quality.Quality.ToString()); diff --git a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs index 61c151d0d..def3780e9 100644 --- a/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs +++ b/src/NzbDrone.Core/Extras/Metadata/Consumers/Xbmc/XbmcMetadata.cs @@ -255,7 +255,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Xbmc { var setElement = new XElement("set"); - setElement.Add(new XElement("name", movie.MovieMetadata.Value.Collection.Name)); + setElement.Add(new XElement("name", movie.MovieMetadata.Value.Collection.Value.Title)); setElement.Add(new XElement("overview")); details.Add(setElement); diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MovieCollectionRootFolderCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MovieCollectionRootFolderCheck.cs new file mode 100644 index 000000000..60f749488 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/MovieCollectionRootFolderCheck.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Disk; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Localization; +using NzbDrone.Core.Movies.Collections; +using NzbDrone.Core.Movies.Events; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(CollectionEditedEvent), CheckOnCondition.Always)] + public class MovieCollectionRootFolderCheck : HealthCheckBase + { + private readonly IMovieCollectionService _collectionService; + private readonly IDiskProvider _diskProvider; + + public MovieCollectionRootFolderCheck(IMovieCollectionService collectionService, IDiskProvider diskProvider, ILocalizationService localizationService) + : base(localizationService) + { + _collectionService = collectionService; + _diskProvider = diskProvider; + } + + public override HealthCheck Check() + { + var collections = _collectionService.GetAllCollections(); + var missingRootFolders = new Dictionary>(); + + foreach (var collection in collections) + { + var rootFolderPath = collection.RootFolderPath; + + if (missingRootFolders.ContainsKey(rootFolderPath)) + { + missingRootFolders[rootFolderPath].Add(collection); + + continue; + } + + if (rootFolderPath.IsNullOrWhiteSpace() || !_diskProvider.FolderExists(rootFolderPath)) + { + missingRootFolders.Add(rootFolderPath, new List { collection }); + } + } + + if (missingRootFolders.Any()) + { + if (missingRootFolders.Count == 1) + { + var missingRootFolder = missingRootFolders.First(); + return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format(_localizationService.GetLocalizedString("MovieCollectionMissingRoot"), FormatRootFolder(missingRootFolder.Key, missingRootFolder.Value)), "#movie-collection-missing-root-folder"); + } + + var message = string.Format(_localizationService.GetLocalizedString("MovieCollectionMultipleMissingRoots"), string.Join(" | ", missingRootFolders.Select(m => FormatRootFolder(m.Key, m.Value)))); + return new HealthCheck(GetType(), HealthCheckResult.Error, message, "#movie-collection-missing-root-folder"); + } + + return new HealthCheck(GetType()); + } + + private string FormatRootFolder(string rootFolderPath, List collections) + { + return $"{rootFolderPath} ({string.Join(", ", collections.Select(l => l.Title))})"; + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedCollections.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedCollections.cs new file mode 100644 index 000000000..bb4c39e3a --- /dev/null +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupOrphanedCollections.cs @@ -0,0 +1,28 @@ +using Dapper; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Housekeeping.Housekeepers +{ + public class CleanupOrphanedCollections : IHousekeepingTask + { + private readonly IMainDatabase _database; + + public CleanupOrphanedCollections(IMainDatabase database) + { + _database = database; + } + + public void Clean() + { + using (var mapper = _database.OpenConnection()) + { + mapper.Execute(@"DELETE FROM Collections + WHERE Id IN ( + SELECT Collections.Id FROM Collections + LEFT OUTER JOIN Movies + ON Collections.Id = Movies.CollectionId + WHERE Movies.Id IS NULL)"); + } + } + } +} diff --git a/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs index 97fadae36..0a8c0eeef 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListDefinition.cs @@ -13,7 +13,7 @@ namespace NzbDrone.Core.ImportLists public bool Enabled { get; set; } public bool EnableAuto { get; set; } - public bool ShouldMonitor { get; set; } + public MonitorTypes Monitor { get; set; } public MovieStatusType MinimumAvailability { get; set; } public int ProfileId { get; set; } public string RootFolderPath { get; set; } diff --git a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs index 7fb5a01ee..204db5b89 100644 --- a/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs +++ b/src/NzbDrone.Core/ImportLists/ImportListSyncService.cs @@ -91,11 +91,11 @@ namespace NzbDrone.Core.ImportLists // Append Artist if not already in DB or already on add list if (moviesToAdd.All(s => s.TmdbId != report.TmdbId)) { - var monitored = importList.ShouldMonitor; + var monitorType = importList.Monitor; moviesToAdd.Add(new Movie { - Monitored = monitored, + Monitored = monitorType != MonitorTypes.None, RootFolderPath = importList.RootFolderPath, ProfileId = importList.ProfileId, MinimumAvailability = importList.MinimumAvailability, @@ -106,7 +106,8 @@ namespace NzbDrone.Core.ImportLists ImdbId = report.ImdbId, AddOptions = new AddMovieOptions { - SearchForMovie = monitored && importList.SearchOnAdd, + SearchForMovie = monitorType != MonitorTypes.None && importList.SearchOnAdd, + Monitor = monitorType } }); } diff --git a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionImport.cs b/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionImport.cs deleted file mode 100644 index a7f691c13..000000000 --- a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionImport.cs +++ /dev/null @@ -1,43 +0,0 @@ -using NLog; -using NzbDrone.Common.Cloud; -using NzbDrone.Common.Http; -using NzbDrone.Core.Configuration; -using NzbDrone.Core.MetadataSource; -using NzbDrone.Core.Parser; - -namespace NzbDrone.Core.ImportLists.TMDb.Collection -{ - public class TMDbCollectionImport : TMDbImportListBase - { - public TMDbCollectionImport(IRadarrCloudRequestBuilder requestBuilder, - IHttpClient httpClient, - IImportListStatusService importListStatusService, - IConfigService configService, - IParsingService parsingService, - ISearchForNewMovie searchForNewMovie, - Logger logger) - : base(requestBuilder, httpClient, importListStatusService, configService, parsingService, searchForNewMovie, logger) - { - } - - public override string Name => "TMDb Collection"; - public override bool Enabled => true; - public override bool EnableAuto => false; - - public override IParseImportListResponse GetParser() - { - return new TMDbCollectionParser(); - } - - public override IImportListRequestGenerator GetRequestGenerator() - { - return new TMDbCollectionRequestGenerator() - { - RequestBuilder = _requestBuilder, - Settings = Settings, - Logger = _logger, - HttpClient = _httpClient - }; - } - } -} diff --git a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionParser.cs b/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionParser.cs deleted file mode 100644 index b97f29e7c..000000000 --- a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionParser.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.ImportLists.ImportListMovies; - -namespace NzbDrone.Core.ImportLists.TMDb.Collection -{ - public class TMDbCollectionParser : TMDbParser - { - public override IList ParseResponse(ImportListResponse importResponse) - { - var movies = new List(); - - if (!PreProcess(importResponse)) - { - return movies; - } - - var jsonResponse = JsonConvert.DeserializeObject(importResponse.Content); - - // no movies were return - if (jsonResponse == null) - { - return movies; - } - - foreach (var movie in jsonResponse.Parts) - { - // Movies with no Year Fix - if (string.IsNullOrWhiteSpace(movie.ReleaseDate)) - { - continue; - } - - movies.AddIfNotNull(MapListMovie(movie)); - } - - return movies; - } - } -} diff --git a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionRequestGenerator.cs b/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionRequestGenerator.cs deleted file mode 100644 index 10ac3342a..000000000 --- a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionRequestGenerator.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using NLog; -using NzbDrone.Common.Http; - -namespace NzbDrone.Core.ImportLists.TMDb.Collection -{ - public class TMDbCollectionRequestGenerator : IImportListRequestGenerator - { - public TMDbCollectionSettings Settings { get; set; } - public IHttpClient HttpClient { get; set; } - public IHttpRequestBuilderFactory RequestBuilder { get; set; } - public Logger Logger { get; set; } - - public TMDbCollectionRequestGenerator() - { - } - - public virtual ImportListPageableRequestChain GetMovies() - { - var pageableRequests = new ImportListPageableRequestChain(); - - pageableRequests.Add(GetMoviesRequest()); - - return pageableRequests; - } - - private IEnumerable GetMoviesRequest() - { - Logger.Info($"Importing TMDb movies from collection: {Settings.CollectionId}"); - - yield return new ImportListRequest(RequestBuilder.Create() - .SetSegment("api", "3") - .SetSegment("route", "collection") - .SetSegment("id", Settings.CollectionId) - .SetSegment("secondaryRoute", "") - .Build()); - } - } -} diff --git a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionSettings.cs b/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionSettings.cs deleted file mode 100644 index 59200a39f..000000000 --- a/src/NzbDrone.Core/ImportLists/TMDb/Collection/TMDbCollectionSettings.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Text.RegularExpressions; -using FluentValidation; -using NzbDrone.Core.Annotations; - -namespace NzbDrone.Core.ImportLists.TMDb.Collection -{ - public class TMDbCollectionSettingsValidator : TMDbSettingsBaseValidator - { - public TMDbCollectionSettingsValidator() - : base() - { - RuleFor(c => c.CollectionId).Matches(@"^[1-9][0-9]*$", RegexOptions.IgnoreCase); - } - } - - public class TMDbCollectionSettings : TMDbSettingsBase - { - protected override AbstractValidator Validator => new TMDbCollectionSettingsValidator(); - - public TMDbCollectionSettings() - { - CollectionId = ""; - } - - [FieldDefinition(1, Label = "Collection Id", Type = FieldType.Textbox, HelpText = "TMDb Id of Collection to Follow")] - public string CollectionId { get; set; } - } -} diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 498cb53d2..a20829a8f 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -101,6 +101,12 @@ namespace NzbDrone.Core.Jobs TypeName = typeof(CleanUpRecycleBinCommand).FullName }, + new ScheduledTask + { + Interval = 24 * 60, + TypeName = typeof(RefreshCollectionsCommand).FullName + }, + new ScheduledTask { Interval = GetBackupInterval(), diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index ed11b2abb..b5b6a3679 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -15,6 +15,7 @@ "AddingTag": "Adding tag", "AddList": "Add List", "AddListExclusion": "Add List Exclusion", + "AddMissingMovies": "Add Missing Movies", "AddMovie": "Add Movie", "AddMovies": "Add Movies", "AddMoviesMonitored": "Add Movies Monitored", @@ -33,6 +34,7 @@ "Agenda": "Agenda", "AgeWhenGrabbed": "Age (when grabbed)", "All": "All", + "AllCollectionsHiddenDueToFilter": "All collections are hidden due to applied filter.", "AllFiles": "All Files", "AllMoviesHiddenDueToFilter": "All movies are hidden due to applied filter.", "AllMoviesInPathHaveBeenImported": "All movies in {0} have been imported", @@ -137,6 +139,7 @@ "Close": "Close", "CloseCurrentModal": "Close Current Modal", "Collection": "Collection", + "CollectionsSelectedInterp": "{0} Collections(s) Selected", "ColonReplacement": "Colon Replacement", "ColonReplacementFormatHelpText": "Change how Radarr handles colon replacement", "Columns": "Columns", @@ -536,11 +539,14 @@ "Mode": "Mode", "Monday": "Monday", "Monitor": "Monitor", + "MonitorCollection": "Monitor Collection", "Monitored": "Monitored", + "MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library", "MonitoredHelpText": "Download movie if available", "MonitoredOnly": "Monitored Only", "MonitoredStatus": "Monitored/Status", "MonitorMovie": "Monitor Movie", + "MonitorMovies": "Monitor Movies", "Month": "Month", "Months": "Months", "More": "More", @@ -553,7 +559,10 @@ "MoveFolders2": "Would you like to move the movie files from '{0}' to '{1}' ?", "Movie": "Movie", "MovieAlreadyExcluded": "Movie already Excluded", + "MovieAndCollection": "Movie and Collection", "MovieChat": "Movie Chat", + "MovieCollectionMissingRoot": "Missing root folder for movie collection: {0}", + "MovieCollectionMultipleMissingRoots": "Multiple root folders are missing for movie collections: {0}", "MovieDetailsNextMovie": "Movie Details: Next Movie", "MovieDetailsPreviousMovie": "Movie Details: Previous Movie", "MovieEditor": "Movie Editor", @@ -576,6 +585,7 @@ "MovieIsRecommend": "Movie is recommended based on recent addition", "MovieIsUnmonitored": "Movie is unmonitored", "MovieNaming": "Movie Naming", + "MovieOnly": "Movie Only", "Movies": "Movies", "MoviesSelectedInterp": "{0} Movie(s) Selected", "MovieTitle": "Movie Title", @@ -599,6 +609,7 @@ "NoBackupsAreAvailable": "No backups are available", "NoChange": "No Change", "NoChanges": "No Changes", + "NoCollections": "No collections found, to get started you'll want to add a new movie, or import some existing ones", "NoEventsFound": "No events found", "NoHistory": "No history", "NoLeaveIt": "No, Leave It", @@ -646,8 +657,6 @@ "OpenThisModal": "Open This Modal", "Options": "Options", "Organize": "Organize", - "OriginalTitle": "Original Title", - "OriginalLanguage": "Original Language", "OrganizeAndRename": "Organize & Rename", "OrganizeConfirm": "Are you sure you want to organize all files in the {0} selected movie(s)?", "OrganizeModalAllPathsRelative": "All paths are relative to:", @@ -656,6 +665,8 @@ "OrganizeModalSuccess": "Success! My work is done, no files to rename.", "OrganizeSelectedMovies": "Organize Selected Movies", "Original": "Original", + "OriginalLanguage": "Original Language", + "OriginalTitle": "Original Title", "OutputPath": "Output Path", "Overview": "Overview", "OverviewOptions": "Overview Options", @@ -748,6 +759,7 @@ "Redownload": "Redownload", "Refresh": "Refresh", "RefreshAndScan": "Refresh & Scan", + "RefreshCollections": "Refresh Collections", "RefreshInformationAndScanDisk": "Refresh information and scan disk", "RefreshLists": "Refresh Lists", "RefreshMovie": "Refresh movie", @@ -862,7 +874,8 @@ "SearchMissing": "Search Missing", "SearchMovie": "Search Movie", "SearchOnAdd": "Search on Add", - "SearchOnAddHelpText": "Search for movies on this list when added to Radarr", + "SearchOnAddCollectionHelpText": "Search for movies on this collection when added to library", + "SearchOnAddHelpText": "Search for movies on this list when added to library", "SearchSelected": "Search Selected", "Seconds": "Seconds", "Security": "Security", @@ -898,12 +911,13 @@ "SettingsTimeFormat": "Time Format", "SettingsWeekColumnHeader": "Week Column Header", "SettingsWeekColumnHeaderHelpText": "Shown above each column when week is the active view", - "ShouldMonitorHelpText": "If enabled, movies added by this list are added and monitored", + "ShouldMonitorHelpText": "Should Movies or Collections added by this list be added as monitored", "ShowAdvanced": "Show Advanced", "ShowAsAllDayEvents": "Show as All-Day Events", "ShowCertification": "Show Certification", "ShowCinemaRelease": "Show Cinema Release Date", "showCinemaReleaseHelpText": "Show cinema release date under poster", + "ShowCollectionDetails": "Show Collection Status", "ShowCutoffUnmetIconHelpText": "Show icon for files when the cutoff hasn't been met", "ShowDateAdded": "Show Date Added", "ShowGenres": "Show Genres", @@ -912,6 +926,7 @@ "ShowMovieInformation": "Show Movie Information", "ShowMovieInformationHelpText": "Show movie genres and certification", "ShownClickToHide": "Shown, click to hide", + "ShowOverview": "Show Overview", "ShowPath": "Show Path", "ShowQualityProfile": "Show Quality Profile", "ShowQualityProfileHelpText": "Show quality profile under poster", @@ -1026,6 +1041,7 @@ "UnableToLoadAltTitle": "Unable to load alternative titles.", "UnableToLoadBackups": "Unable to load backups", "UnableToLoadBlocklist": "Unable to load blocklist", + "UnableToLoadCollections": "Unable to load collections", "UnableToLoadCustomFormats": "Unable to load Custom Formats", "UnableToLoadDelayProfiles": "Unable to load Delay Profiles", "UnableToLoadDownloadClientOptions": "Unable to load download client options", diff --git a/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs index 55c470b82..b2323f7b6 100644 --- a/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs +++ b/src/NzbDrone.Core/MetadataSource/IProvideMovieInfo.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Credits; namespace NzbDrone.Core.MetadataSource @@ -9,6 +10,7 @@ namespace NzbDrone.Core.MetadataSource { MovieMetadata GetMovieByImdbId(string imdbId); Tuple> GetMovieInfo(int tmdbId); + MovieCollection GetCollectionInfo(int tmdbId); List GetBulkMovieInfo(List tmdbIds); HashSet GetChangedMovies(DateTime startTime); diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/CollectionResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/CollectionResource.cs index fb4194200..65603bcac 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/CollectionResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/CollectionResource.cs @@ -5,7 +5,9 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource public class CollectionResource { public string Name { get; set; } + public string Overview { get; set; } public int TmdbId { get; set; } public List Images { get; set; } + public List Parts { get; set; } } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index eac68b37e..45d2a99b1 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -14,6 +14,7 @@ using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook.Resource; using NzbDrone.Core.Movies; using NzbDrone.Core.Movies.AlternativeTitles; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Credits; using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Parser; @@ -101,6 +102,35 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return new Tuple>(movie, credits.ToList()); } + public MovieCollection GetCollectionInfo(int tmdbId) + { + var httpRequest = _radarrMetadata.Create() + .SetSegment("route", "movie/collection") + .Resource(tmdbId.ToString()) + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new MovieNotFoundException(tmdbId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var collection = MapCollection(httpResponse.Resource); + + return collection; + } + public List GetBulkMovieInfo(List tmdbIds) { var httpRequest = _radarrMetadata.Create() @@ -257,7 +287,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook if (resource.Collection != null) { - movie.Collection = new MovieCollection { Name = resource.Collection.Name, TmdbId = resource.Collection.TmdbId }; + movie.Collection = MapCollection(resource.Collection); } return movie; @@ -470,6 +500,45 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return movie; } + private MovieCollection MapCollection(CollectionResource arg) + { + var collection = new MovieCollection + { + TmdbId = arg.TmdbId, + Title = arg.Name, + Overview = arg.Overview, + CleanTitle = arg.Name.CleanMovieTitle(), + SortTitle = Parser.Parser.NormalizeTitle(arg.Name), + Images = arg.Images?.Select(MapImage).ToList() ?? new List(), + Movies = arg.Parts?.Select(x => MapCollectionMovie(x)).ToList() ?? new List() + }; + + return collection; + } + + private static CollectionMovie MapCollectionMovie(MovieResource movieResult) + { + var movie = new CollectionMovie + { + TmdbId = movieResult.TmdbId, + ImdbId = movieResult.ImdbId, + Overview = movieResult.Overview, + Title = movieResult.Title, + SortTitle = Parser.Parser.NormalizeTitle(movieResult.Title), + Images = movieResult.Images.Select(MapImage).ToList(), + Year = movieResult.Year, + Ratings = MapRatings(movieResult.MovieRatings) ?? new Ratings(), + Genres = movieResult.Genres + }; + + if (movieResult.Runtime != null) + { + movie.Runtime = movieResult.Runtime.Value; + } + + return movie; + } + private static Credit MapCast(CastResource arg) { var newActor = new Credit diff --git a/src/NzbDrone.Core/Movies/AddMovieService.cs b/src/NzbDrone.Core/Movies/AddMovieService.cs index b2a123532..f245b5490 100644 --- a/src/NzbDrone.Core/Movies/AddMovieService.cs +++ b/src/NzbDrone.Core/Movies/AddMovieService.cs @@ -8,6 +8,7 @@ using NLog; using NzbDrone.Common.EnsureThat; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser; using NzbDrone.Core.RootFolders; @@ -24,6 +25,7 @@ namespace NzbDrone.Core.Movies { private readonly IMovieService _movieService; private readonly IMovieMetadataService _movieMetadataService; + private readonly IAddMovieCollectionService _collectionService; private readonly IProvideMovieInfo _movieInfo; private readonly IBuildFileNames _fileNameBuilder; private readonly IAddMovieValidator _addMovieValidator; @@ -31,6 +33,7 @@ namespace NzbDrone.Core.Movies public AddMovieService(IMovieService movieService, IMovieMetadataService movieMetadataService, + IAddMovieCollectionService collectionService, IProvideMovieInfo movieInfo, IBuildFileNames fileNameBuilder, IAddMovieValidator addMovieValidator, @@ -38,6 +41,7 @@ namespace NzbDrone.Core.Movies { _movieService = movieService; _movieMetadataService = movieMetadataService; + _collectionService = collectionService; _movieInfo = movieInfo; _fileNameBuilder = fileNameBuilder; _addMovieValidator = addMovieValidator; @@ -55,6 +59,13 @@ namespace NzbDrone.Core.Movies _movieMetadataService.Upsert(newMovie.MovieMetadata.Value); newMovie.MovieMetadataId = newMovie.MovieMetadata.Value.Id; + + // add collection + if (newMovie.Collection != null) + { + var newCollection = _collectionService.AddMovieCollection(BuildCollection(newMovie)); + newMovie.CollectionId = newCollection.Id; + } _movieService.AddMovie(newMovie); @@ -76,6 +87,14 @@ namespace NzbDrone.Core.Movies movie = SetPropertiesAndValidate(movie); movie.Added = added; + + // add collection + if (movie.Collection != null) + { + var newCollection = _collectionService.AddMovieCollection(BuildCollection(movie)); + movie.CollectionId = newCollection.Id; + } + moviesToAdd.Add(movie); } catch (ValidationException ex) @@ -118,6 +137,23 @@ namespace NzbDrone.Core.Movies return movie; } + private MovieCollection BuildCollection(Movie newMovie) + { + var collection = newMovie.Collection.Value; + collection.Monitored = newMovie.AddOptions?.Monitor == MonitorTypes.MovieAndCollection; + collection.SearchOnAdd = newMovie.AddOptions?.SearchForMovie ?? false; + collection.QualityProfileId = newMovie.ProfileId; + collection.MinimumAvailability = newMovie.MinimumAvailability; + collection.RootFolderPath = newMovie.RootFolderPath; + + if (newMovie.RootFolderPath == null) + { + collection.RootFolderPath = _folderService.GetBestRootFolderPath(newMovie.Path); + } + + return collection; + } + private Movie SetPropertiesAndValidate(Movie newMovie) { if (string.IsNullOrWhiteSpace(newMovie.Path)) diff --git a/src/NzbDrone.Core/Movies/Collections/AddMovieCollectionService.cs b/src/NzbDrone.Core/Movies/Collections/AddMovieCollectionService.cs new file mode 100644 index 000000000..4b4788bb8 --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/AddMovieCollectionService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using FluentValidation; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Movies.Collections +{ + public interface IAddMovieCollectionService + { + MovieCollection AddMovieCollection(MovieCollection newCollection); + } + + public class AddMovieCollectionService : IAddMovieCollectionService + { + private readonly IMovieCollectionService _collectionService; + private readonly IProvideMovieInfo _movieInfo; + private readonly Logger _logger; + + public AddMovieCollectionService(IMovieCollectionService collectionService, + IProvideMovieInfo movieInfo, + Logger logger) + { + _collectionService = collectionService; + _movieInfo = movieInfo; + _logger = logger; + } + + public MovieCollection AddMovieCollection(MovieCollection newCollection) + { + Ensure.That(newCollection, () => newCollection).IsNotNull(); + + var existingCollection = _collectionService.FindByTmdbId(newCollection.TmdbId); + + if (existingCollection != null) + { + return existingCollection; + } + + newCollection = AddSkyhookData(newCollection); + newCollection = SetPropertiesAndValidate(newCollection); + + _logger.Info("Adding Collection {0}", newCollection); + + _collectionService.AddCollection(newCollection); + + return newCollection; + } + + private MovieCollection AddSkyhookData(MovieCollection newCollection) + { + MovieCollection collection; + + try + { + collection = _movieInfo.GetCollectionInfo(newCollection.TmdbId); + } + catch (MovieNotFoundException) + { + _logger.Error("TmdbId {0} was not found, it may have been removed from TMDb.", newCollection.TmdbId); + + throw new ValidationException(new List + { + new ValidationFailure("TmdbId", $"A collection with this ID was not found.", newCollection.TmdbId) + }); + } + + collection.ApplyChanges(newCollection); + + return collection; + } + + private MovieCollection SetPropertiesAndValidate(MovieCollection newCollection) + { + newCollection.CleanTitle = newCollection.Title.CleanMovieTitle(); + newCollection.SortTitle = MovieTitleNormalizer.Normalize(newCollection.Title, newCollection.TmdbId); + newCollection.Added = DateTime.UtcNow; + + return newCollection; + } + } +} diff --git a/src/NzbDrone.Core/Movies/Collections/CollectionMovie.cs b/src/NzbDrone.Core/Movies/Collections/CollectionMovie.cs new file mode 100644 index 000000000..03a89f7de --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/CollectionMovie.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Movies.Collections +{ + public class CollectionMovie : IEmbeddedDocument + { + public int TmdbId { get; set; } + public string ImdbId { get; set; } + public string Title { get; set; } + public string CleanTitle { get; set; } + public string SortTitle { get; set; } + public string Overview { get; set; } + public int Runtime { get; set; } + public List Images { get; set; } + public int Year { get; set; } + public Ratings Ratings { get; set; } + public List Genres { get; set; } + } +} diff --git a/src/NzbDrone.Core/Movies/Collections/MovieCollection.cs b/src/NzbDrone.Core/Movies/Collections/MovieCollection.cs new file mode 100644 index 000000000..d5238b0cd --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/MovieCollection.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Movies.Collections +{ + public class MovieCollection : ModelBase + { + public MovieCollection() + { + Images = new List(); + Movies = new List(); + } + + public string Title { get; set; } + public string CleanTitle { get; set; } + public string SortTitle { get; set; } + public int TmdbId { get; set; } + public string Overview { get; set; } + public bool Monitored { get; set; } + public int QualityProfileId { get; set; } + public string RootFolderPath { get; set; } + public bool SearchOnAdd { get; set; } + public MovieStatusType MinimumAvailability { get; set; } + public DateTime? LastInfoSync { get; set; } + public List Images { get; set; } + public DateTime Added { get; set; } + public List Movies { get; set; } + + public void ApplyChanges(MovieCollection otherCollection) + { + TmdbId = otherCollection.TmdbId; + + Monitored = otherCollection.Monitored; + SearchOnAdd = otherCollection.SearchOnAdd; + QualityProfileId = otherCollection.QualityProfileId; + MinimumAvailability = otherCollection.MinimumAvailability; + RootFolderPath = otherCollection.RootFolderPath; + } + } +} diff --git a/src/NzbDrone.Core/Movies/Collections/MovieCollectionAddedHandler.cs b/src/NzbDrone.Core/Movies/Collections/MovieCollectionAddedHandler.cs new file mode 100644 index 000000000..daf953ca7 --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/MovieCollectionAddedHandler.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies.Commands; +using NzbDrone.Core.Movies.Events; + +namespace NzbDrone.Core.Movies +{ + public class MovieCollectionAddedHandler : IHandle + { + private readonly IManageCommandQueue _commandQueueManager; + + public MovieCollectionAddedHandler(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(CollectionAddedEvent message) + { + _commandQueueManager.Push(new RefreshCollectionsCommand(new List { message.Collection.Id })); + } + } +} diff --git a/src/NzbDrone.Core/Movies/Collections/MovieCollectionRepository.cs b/src/NzbDrone.Core/Movies/Collections/MovieCollectionRepository.cs new file mode 100644 index 000000000..2017147fc --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/MovieCollectionRepository.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Movies.Collections +{ + public interface IMovieCollectionRepository : IBasicRepository + { + public MovieCollection GetByTmdbId(int tmdbId); + bool UpsertMany(List data); + } + + public class MovieCollectionRepository : BasicRepository, IMovieCollectionRepository + { + public MovieCollectionRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public MovieCollection GetByTmdbId(int tmdbId) + { + return Query(x => x.TmdbId == tmdbId).FirstOrDefault(); + } + + public List GetByTmdbId(List tmdbIds) + { + return Query(x => Enumerable.Contains(tmdbIds, x.TmdbId)); + } + + public bool UpsertMany(List data) + { + var existingMetadata = GetByTmdbId(data.Select(x => x.TmdbId).ToList()); + var updateCollectionList = new List(); + var addCollectionList = new List(); + int upToDateMetadataCount = 0; + + foreach (var collection in data) + { + var existing = existingMetadata.SingleOrDefault(x => x.TmdbId == collection.TmdbId); + if (existing != null) + { + // populate Id in remote data + collection.Id = existing.Id; + + // responses vary, so try adding remote to what we have + if (!collection.Equals(existing)) + { + updateCollectionList.Add(collection); + } + else + { + upToDateMetadataCount++; + } + } + else + { + addCollectionList.Add(collection); + } + } + + UpdateMany(updateCollectionList); + InsertMany(addCollectionList); + + return updateCollectionList.Count > 0 || addCollectionList.Count > 0; + } + } +} diff --git a/src/NzbDrone.Core/Movies/Collections/MovieCollectionService.cs b/src/NzbDrone.Core/Movies/Collections/MovieCollectionService.cs new file mode 100644 index 000000000..0d595219b --- /dev/null +++ b/src/NzbDrone.Core/Movies/Collections/MovieCollectionService.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies.Events; + +namespace NzbDrone.Core.Movies.Collections +{ + public interface IMovieCollectionService + { + MovieCollection AddCollection(MovieCollection collection); + MovieCollection GetCollection(int id); + MovieCollection FindByTmdbId(int tmdbId); + IEnumerable GetCollections(IEnumerable ids); + List GetAllCollections(); + MovieCollection UpdateCollection(MovieCollection collection); + void RemoveCollection(MovieCollection collection); + bool Upsert(MovieCollection collection); + bool UpsertMany(List collections); + } + + public class MovieCollectionService : IMovieCollectionService, IHandleAsync + { + private readonly IMovieCollectionRepository _repo; + private readonly IMovieService _movieService; + private readonly IEventAggregator _eventAggregator; + + public MovieCollectionService(IMovieCollectionRepository repo, IMovieService movieService, IEventAggregator eventAggregator) + { + _repo = repo; + _movieService = movieService; + _eventAggregator = eventAggregator; + } + + public MovieCollection AddCollection(MovieCollection newCollection) + { + var existing = _repo.GetByTmdbId(newCollection.TmdbId); + + if (existing == null) + { + var collection = _repo.Insert(newCollection); + + _eventAggregator.PublishEvent(new CollectionAddedEvent(collection)); + + return collection; + } + + return existing; + } + + public MovieCollection GetCollection(int id) + { + return _repo.Get(id); + } + + public IEnumerable GetCollections(IEnumerable ids) + { + return _repo.Get(ids); + } + + public List GetAllCollections() + { + return _repo.All().ToList(); + } + + public MovieCollection UpdateCollection(MovieCollection collection) + { + var storedCollection = GetCollection(collection.Id); + + var updatedCollection = _repo.Update(collection); + + _eventAggregator.PublishEvent(new CollectionEditedEvent(updatedCollection, storedCollection)); + + return updatedCollection; + } + + public void RemoveCollection(MovieCollection collection) + { + _repo.Delete(collection); + + _eventAggregator.PublishEvent(new CollectionDeletedEvent(collection)); + } + + public bool Upsert(MovieCollection collection) + { + return _repo.UpsertMany(new List { collection }); + } + + public bool UpsertMany(List collections) + { + return _repo.UpsertMany(collections); + } + + public void HandleAsync(MoviesDeletedEvent message) + { + var collections = message.Movies.Select(x => x.CollectionId).Distinct(); + + foreach (var collectionId in collections) + { + if (collectionId == 0 || _movieService.GetMoviesByCollectionId(collectionId).Any()) + { + continue; + } + + var collection = GetCollection(collectionId); + + _eventAggregator.PublishEvent(new CollectionDeletedEvent(collection)); + + _repo.Delete(collectionId); + } + } + + public MovieCollection FindByTmdbId(int tmdbId) + { + return _repo.GetByTmdbId(tmdbId); + } + } +} diff --git a/src/NzbDrone.Core/Movies/Commands/RefreshCollectionsCommand.cs b/src/NzbDrone.Core/Movies/Commands/RefreshCollectionsCommand.cs new file mode 100644 index 000000000..e33e40277 --- /dev/null +++ b/src/NzbDrone.Core/Movies/Commands/RefreshCollectionsCommand.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Movies.Commands +{ + public class RefreshCollectionsCommand : Command + { + public List CollectionIds { get; set; } + + public RefreshCollectionsCommand() + { + CollectionIds = new List(); + } + + public RefreshCollectionsCommand(List collectionIds) + { + CollectionIds = collectionIds; + } + + public override bool SendUpdatesToClient => true; + + public override bool UpdateScheduledTask => !CollectionIds.Any(); + } +} diff --git a/src/NzbDrone.Core/Movies/Events/CollectionAddedEvent.cs b/src/NzbDrone.Core/Movies/Events/CollectionAddedEvent.cs new file mode 100644 index 000000000..19e1351ff --- /dev/null +++ b/src/NzbDrone.Core/Movies/Events/CollectionAddedEvent.cs @@ -0,0 +1,15 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Movies.Collections; + +namespace NzbDrone.Core.Movies.Events +{ + public class CollectionAddedEvent : IEvent + { + public MovieCollection Collection { get; private set; } + + public CollectionAddedEvent(MovieCollection collection) + { + Collection = collection; + } + } +} diff --git a/src/NzbDrone.Core/Movies/Events/CollectionDeletedEvent.cs b/src/NzbDrone.Core/Movies/Events/CollectionDeletedEvent.cs new file mode 100644 index 000000000..1bcce570d --- /dev/null +++ b/src/NzbDrone.Core/Movies/Events/CollectionDeletedEvent.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Movies.Collections; + +namespace NzbDrone.Core.Movies.Events +{ + public class CollectionDeletedEvent : IEvent + { + public MovieCollection Collection { get; private set; } + + public CollectionDeletedEvent(MovieCollection collection) + { + Collection = collection; + } + } +} diff --git a/src/NzbDrone.Core/Movies/Events/CollectionEditedEvent.cs b/src/NzbDrone.Core/Movies/Events/CollectionEditedEvent.cs new file mode 100644 index 000000000..b5453068f --- /dev/null +++ b/src/NzbDrone.Core/Movies/Events/CollectionEditedEvent.cs @@ -0,0 +1,17 @@ +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Movies.Collections; + +namespace NzbDrone.Core.Movies.Events +{ + public class CollectionEditedEvent : IEvent + { + public MovieCollection Collection { get; private set; } + public MovieCollection OldCollection { get; private set; } + + public CollectionEditedEvent(MovieCollection collection, MovieCollection oldCollection) + { + Collection = collection; + OldCollection = oldCollection; + } + } +} diff --git a/src/NzbDrone.Core/Movies/MonitoringOptions.cs b/src/NzbDrone.Core/Movies/MonitoringOptions.cs index e6145184d..87519bbb1 100644 --- a/src/NzbDrone.Core/Movies/MonitoringOptions.cs +++ b/src/NzbDrone.Core/Movies/MonitoringOptions.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Datastore; +using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Movies { @@ -6,5 +6,13 @@ namespace NzbDrone.Core.Movies { public bool IgnoreEpisodesWithFiles { get; set; } public bool IgnoreEpisodesWithoutFiles { get; set; } + public MonitorTypes Monitor { get; set; } + } + + public enum MonitorTypes + { + MovieOnly, + MovieAndCollection, + None } } diff --git a/src/NzbDrone.Core/Movies/Movie.cs b/src/NzbDrone.Core/Movies/Movie.cs index cb02a39a1..52c928f5a 100644 --- a/src/NzbDrone.Core/Movies/Movie.cs +++ b/src/NzbDrone.Core/Movies/Movie.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Profiles; namespace NzbDrone.Core.Movies @@ -24,6 +25,9 @@ namespace NzbDrone.Core.Movies public string Path { get; set; } public LazyLoaded MovieMetadata { get; set; } + + public int CollectionId { get; set; } + public LazyLoaded Collection { get; set; } public string RootFolderPath { get; set; } public DateTime Added { get; set; } diff --git a/src/NzbDrone.Core/Movies/MovieCollection.cs b/src/NzbDrone.Core/Movies/MovieCollection.cs deleted file mode 100644 index 67500bd25..000000000 --- a/src/NzbDrone.Core/Movies/MovieCollection.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.Movies -{ - public class MovieCollection : IEmbeddedDocument - { - public MovieCollection() - { - Images = new List(); - } - - public string Name { get; set; } - public int TmdbId { get; set; } - public List Images { get; set; } - } -} diff --git a/src/NzbDrone.Core/Movies/MovieRepository.cs b/src/NzbDrone.Core/Movies/MovieRepository.cs index 13a983bc3..96337bfb0 100644 --- a/src/NzbDrone.Core/Movies/MovieRepository.cs +++ b/src/NzbDrone.Core/Movies/MovieRepository.cs @@ -7,6 +7,7 @@ using NzbDrone.Core.Datastore; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies.AlternativeTitles; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; @@ -23,6 +24,7 @@ namespace NzbDrone.Core.Movies List MoviesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored); PagingSpec MoviesWithoutFiles(PagingSpec pagingSpec); List GetMoviesByFileId(int fileId); + List GetMoviesByCollectionId(int collectionId); void SetFileId(int fileId, int movieId); PagingSpec MoviesWhereCutoffUnmet(PagingSpec pagingSpec, List qualitiesBelowCutoff); Movie FindByPath(string path); @@ -221,6 +223,11 @@ namespace NzbDrone.Core.Movies return Query(x => x.MovieFileId == fileId); } + public List GetMoviesByCollectionId(int collectionId) + { + return Query(x => x.CollectionId == collectionId); + } + public void SetFileId(int fileId, int movieId) { SetFields(new Movie { Id = movieId, MovieFileId = fileId }, movie => movie.MovieFileId); diff --git a/src/NzbDrone.Core/Movies/MovieScannedHandler.cs b/src/NzbDrone.Core/Movies/MovieScannedHandler.cs index 41a3a0885..e3fde9249 100644 --- a/src/NzbDrone.Core/Movies/MovieScannedHandler.cs +++ b/src/NzbDrone.Core/Movies/MovieScannedHandler.cs @@ -4,6 +4,7 @@ using NzbDrone.Core.IndexerSearch; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies.Collections; namespace NzbDrone.Core.Movies { @@ -11,15 +12,18 @@ namespace NzbDrone.Core.Movies IHandle { private readonly IMovieService _movieService; + private readonly IMovieCollectionService _collectionService; private readonly IManageCommandQueue _commandQueueManager; private readonly Logger _logger; public MovieScannedHandler(IMovieService movieService, + IMovieCollectionService collectionService, IManageCommandQueue commandQueueManager, Logger logger) { _movieService = movieService; + _collectionService = collectionService; _commandQueueManager = commandQueueManager; _logger = logger; } diff --git a/src/NzbDrone.Core/Movies/MovieService.cs b/src/NzbDrone.Core/Movies/MovieService.cs index 4373cb364..21ac25d46 100644 --- a/src/NzbDrone.Core/Movies/MovieService.cs +++ b/src/NzbDrone.Core/Movies/MovieService.cs @@ -34,6 +34,7 @@ namespace NzbDrone.Core.Movies List AllMovieTmdbIds(); bool MovieExists(Movie movie); List GetMoviesByFileId(int fileId); + List GetMoviesByCollectionId(int collectionId); List GetMoviesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored); PagingSpec MoviesWithoutFiles(PagingSpec pagingSpec); void SetFileId(Movie movie, MovieFile movieFile); @@ -88,15 +89,17 @@ namespace NzbDrone.Core.Movies public Movie AddMovie(Movie newMovie) { - _movieRepository.Insert(newMovie); - _eventAggregator.PublishEvent(new MovieAddedEvent(GetMovie(newMovie.Id))); + var movie = _movieRepository.Insert(newMovie); - return newMovie; + _eventAggregator.PublishEvent(new MovieAddedEvent(GetMovie(movie.Id))); + + return movie; } public List AddMovies(List newMovies) { _movieRepository.InsertMany(newMovies); + _eventAggregator.PublishEvent(new MoviesImportedEvent(newMovies.Select(s => s.Id).ToList())); return newMovies; @@ -293,6 +296,11 @@ namespace NzbDrone.Core.Movies return _movieRepository.GetMoviesByFileId(fileId); } + public List GetMoviesByCollectionId(int collectionId) + { + return _movieRepository.GetMoviesByCollectionId(collectionId); + } + public List GetMoviesBetweenDates(DateTime start, DateTime end, bool includeUnmonitored) { var movies = _movieRepository.MoviesBetweenDates(start.ToUniversalTime(), end.ToUniversalTime(), includeUnmonitored); diff --git a/src/NzbDrone.Core/Movies/RefreshCollectionService.cs b/src/NzbDrone.Core/Movies/RefreshCollectionService.cs new file mode 100644 index 000000000..6fe095680 --- /dev/null +++ b/src/NzbDrone.Core/Movies/RefreshCollectionService.cs @@ -0,0 +1,150 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Exceptions; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Movies.Collections; +using NzbDrone.Core.Movies.Commands; +using NzbDrone.Core.Movies.Events; + +namespace NzbDrone.Core.Movies +{ + public class RefreshCollectionService : IExecute, IHandle + { + private readonly IProvideMovieInfo _movieInfo; + private readonly IMovieCollectionService _collectionService; + private readonly IMovieService _movieService; + private readonly IAddMovieService _addMovieService; + + private readonly Logger _logger; + + public RefreshCollectionService(IProvideMovieInfo movieInfo, + IMovieCollectionService collectionService, + IMovieService movieService, + IAddMovieService addMovieService, + Logger logger) + { + _movieInfo = movieInfo; + _collectionService = collectionService; + _movieService = movieService; + _addMovieService = addMovieService; + _logger = logger; + } + + private MovieCollection RefreshCollectionInfo(int collectionId) + { + // Get the movie before updating, that way any changes made to the movie after the refresh started, + // but before this movie was refreshed won't be lost. + var collection = _collectionService.GetCollection(collectionId); + + _logger.ProgressInfo("Updating info for {0}", collection.Title); + + MovieCollection collectionInfo; + + try + { + collectionInfo = _movieInfo.GetCollectionInfo(collection.TmdbId); + } + catch (MovieNotFoundException) + { + _collectionService.RemoveCollection(collection); + _logger.Debug("Removing collection not present on TMDb for {0}", collection.Title); + + throw; + } + + collection.Title = collectionInfo.Title; + collection.Overview = collectionInfo.Overview; + collection.CleanTitle = collectionInfo.CleanTitle; + collection.SortTitle = collectionInfo.SortTitle; + collection.LastInfoSync = DateTime.UtcNow; + collection.Images = collectionInfo.Images; + collection.Movies = collectionInfo.Movies; + + _logger.Debug("Finished collection refresh for {0}", collection.Title); + + _collectionService.UpdateCollection(collection); + + return collection; + } + + public bool ShouldRefresh(MovieCollection collection) + { + if (collection.LastInfoSync == null || collection.LastInfoSync < DateTime.UtcNow.AddDays(-15)) + { + _logger.Trace("Collection {0} last updated more than 15 days ago, should refresh.", collection.Title); + return true; + } + + if (collection.LastInfoSync >= DateTime.UtcNow.AddHours(-6)) + { + _logger.Trace("Collection {0} last updated less than 6 hours ago, should not be refreshed.", collection.Title); + return false; + } + + return false; + } + + private void SyncCollectionMovies(MovieCollection collection) + { + if (collection.Monitored) + { + var existingMovies = _movieService.AllMovieTmdbIds(); + + _addMovieService.AddMovies(collection.Movies.Where(m => !existingMovies.Contains(m.TmdbId)).Select(m => new Movie + { + TmdbId = m.TmdbId, + Title = m.Title, + ProfileId = collection.QualityProfileId, + RootFolderPath = collection.RootFolderPath, + MinimumAvailability = collection.MinimumAvailability, + Monitored = true, + Year = m.Year + }).ToList()); + } + } + + public void Execute(RefreshCollectionsCommand message) + { + if (message.CollectionIds.Any()) + { + foreach (var collectionId in message.CollectionIds) + { + var newCollection = RefreshCollectionInfo(collectionId); + SyncCollectionMovies(newCollection); + } + } + else + { + var allCollections = _collectionService.GetAllCollections().OrderBy(c => c.SortTitle).ToList(); + + foreach (var collection in allCollections) + { + try + { + var newCollection = collection; + + if (ShouldRefresh(collection)) + { + newCollection = RefreshCollectionInfo(collection.Id); + } + + SyncCollectionMovies(newCollection); + } + catch (MovieNotFoundException) + { + _logger.Error("Collection '{0}' (TMDb {1}) was not found, it may have been removed from The Movie Database.", collection.Title, collection.TmdbId); + } + } + } + } + + public void Handle(CollectionEditedEvent message) + { + SyncCollectionMovies(message.Collection); + } + } +} diff --git a/src/NzbDrone.Core/Movies/RefreshMovieService.cs b/src/NzbDrone.Core/Movies/RefreshMovieService.cs index 40a08bafe..7faa75699 100644 --- a/src/NzbDrone.Core/Movies/RefreshMovieService.cs +++ b/src/NzbDrone.Core/Movies/RefreshMovieService.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Movies.AlternativeTitles; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Commands; using NzbDrone.Core.Movies.Credits; using NzbDrone.Core.Movies.Events; diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 4d98e174e..758c69e68 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -268,7 +268,7 @@ namespace NzbDrone.Core.Organizer tokenHandlers["{Movie CleanOriginalTitle}"] = m => CleanTitle(movie.MovieMetadata.Value.OriginalTitle) ?? string.Empty; tokenHandlers["{Movie Certification}"] = m => movie.MovieMetadata.Value.Certification ?? string.Empty; - tokenHandlers["{Movie Collection}"] = m => movie.MovieMetadata.Value.Collection?.Name ?? string.Empty; + tokenHandlers["{Movie Collection}"] = m => movie.MovieMetadata.Value.Collection?.Value.Title ?? string.Empty; } private string GetLanguageTitle(Movie movie, string isoCodes) diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 70e4aeecb..831bd0fe6 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -3,6 +3,7 @@ using NzbDrone.Core.CustomFormats; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Organizer @@ -54,7 +55,7 @@ namespace NzbDrone.Core.Organizer { Title = "The Movie: Title", OriginalTitle = "The Original Movie Title", - Collection = new MovieCollection { Name = "The Movie Collection", TmdbId = 123654 }, + Collection = new MovieCollection { Title = "The Movie Collection", TmdbId = 123654 }, Certification = "R", Year = 2010, ImdbId = "tt0066921", diff --git a/src/NzbDrone.Core/Profiles/ProfileService.cs b/src/NzbDrone.Core/Profiles/ProfileService.cs index a21478edd..09e79ed4a 100644 --- a/src/NzbDrone.Core/Profiles/ProfileService.cs +++ b/src/NzbDrone.Core/Profiles/ProfileService.cs @@ -8,6 +8,7 @@ using NzbDrone.Core.Languages; using NzbDrone.Core.Lifecycle; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.Profiles @@ -33,18 +34,21 @@ namespace NzbDrone.Core.Profiles private readonly ICustomFormatService _formatService; private readonly IMovieService _movieService; private readonly IImportListFactory _importListFactory; + private readonly IMovieCollectionService _collectionService; private readonly Logger _logger; public ProfileService(IProfileRepository profileRepository, ICustomFormatService formatService, IMovieService movieService, IImportListFactory importListFactory, + IMovieCollectionService collectionService, Logger logger) { _profileRepository = profileRepository; _formatService = formatService; _movieService = movieService; _importListFactory = importListFactory; + _collectionService = collectionService; _logger = logger; } @@ -60,7 +64,7 @@ namespace NzbDrone.Core.Profiles public void Delete(int id) { - if (_movieService.GetAllMovies().Any(c => c.ProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id)) + if (_movieService.GetAllMovies().Any(c => c.ProfileId == id) || _importListFactory.All().Any(c => c.ProfileId == id) || _collectionService.GetAllCollections().Any(c => c.QualityProfileId == id)) { throw new ProfileInUseException(id); } diff --git a/src/Radarr.Api.V3/Collections/CollectionController.cs b/src/Radarr.Api.V3/Collections/CollectionController.cs new file mode 100644 index 000000000..267e12cd0 --- /dev/null +++ b/src/Radarr.Api.V3/Collections/CollectionController.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; +using NzbDrone.Core.Movies.Events; +using NzbDrone.Core.Organizer; +using NzbDrone.SignalR; +using Radarr.Http; +using Radarr.Http.REST; +using Radarr.Http.REST.Attributes; + +namespace Radarr.Api.V3.Collections +{ + [V3ApiController] + public class CollectionController : RestControllerWithSignalR, + IHandle, + IHandle, + IHandle + { + private readonly IMovieCollectionService _collectionService; + private readonly IMovieService _movieService; + private readonly IBuildFileNames _fileNameBuilder; + + public CollectionController(IBroadcastSignalRMessage signalRBroadcaster, IMovieCollectionService collectionService, IMovieService movieService, IBuildFileNames fileNameBuilder) + : base(signalRBroadcaster) + { + _collectionService = collectionService; + _movieService = movieService; + _fileNameBuilder = fileNameBuilder; + } + + protected override CollectionResource GetResourceById(int id) + { + return MapToResource(_collectionService.GetCollection(id)); + } + + [HttpGet] + public List GetCollections() + { + return _collectionService.GetAllCollections().Select(c => MapToResource(c)).ToList(); + } + + [RestPutById] + public ActionResult UpdateCollection(CollectionResource collectionResource) + { + var collection = _collectionService.GetCollection(collectionResource.Id); + + var model = collectionResource.ToModel(collection); + + var updatedMovie = _collectionService.UpdateCollection(model); + + return Accepted(updatedMovie.Id); + } + + [HttpPut] + public ActionResult UpdateCollections(CollectionUpdateResource collectionResources) + { + var collectionsToUpdate = _collectionService.GetCollections(collectionResources.Collections.Select(c => c.Id)); + var update = new List(); + + foreach (var c in collectionResources.Collections) + { + var collection = collectionsToUpdate.Single(n => n.Id == c.Id); + + if (c.Monitored.HasValue) + { + collection.Monitored = c.Monitored.Value; + } + + if (collectionResources.MonitorMovies.HasValue) + { + var movies = _movieService.GetMoviesByCollectionId(collection.Id); + + movies.ForEach(c => c.Monitored = collectionResources.MonitorMovies.Value); + + _movieService.UpdateMovie(movies, true); + } + + var updatedCollection = _collectionService.UpdateCollection(collection); + update.Add(updatedCollection.ToResource()); + } + + return Accepted(update); + } + + private CollectionResource MapToResource(MovieCollection collection) + { + var resource = collection.ToResource(); + + foreach (var movie in collection.Movies) + { + var movieResource = movie.ToResource(); + movieResource.Folder = _fileNameBuilder.GetMovieFolder(new Movie { Title = movie.Title, Year = movie.Year, ImdbId = movie.ImdbId, TmdbId = movie.TmdbId }); + + resource.Movies.Add(movieResource); + } + + return resource; + } + + [NonAction] + public void Handle(CollectionAddedEvent message) + { + BroadcastResourceChange(ModelAction.Created, MapToResource(message.Collection)); + } + + [NonAction] + public void Handle(CollectionEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Collection)); + } + + [NonAction] + public void Handle(CollectionDeletedEvent message) + { + BroadcastResourceChange(ModelAction.Deleted, MapToResource(message.Collection)); + } + } +} diff --git a/src/Radarr.Api.V3/Collections/CollectionMovieResource.cs b/src/Radarr.Api.V3/Collections/CollectionMovieResource.cs new file mode 100644 index 000000000..e2bc8f21d --- /dev/null +++ b/src/Radarr.Api.V3/Collections/CollectionMovieResource.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; + +namespace Radarr.Api.V3.Collections +{ + public class CollectionMovieResource + { + public int TmdbId { get; set; } + public string ImdbId { get; set; } + public string Title { get; set; } + public string CleanTitle { get; set; } + public string SortTitle { get; set; } + public string Overview { get; set; } + public int Runtime { get; set; } + public List Images { get; set; } + public int Year { get; set; } + public Ratings Ratings { get; set; } + public List Genres { get; set; } + public string Folder { get; set; } + } + + public static class CollectionMovieResourceMapper + { + public static CollectionMovieResource ToResource(this CollectionMovie model) + { + if (model == null) + { + return null; + } + + return new CollectionMovieResource + { + TmdbId = model.TmdbId, + Title = model.Title, + Overview = model.Overview, + SortTitle = model.SortTitle, + Images = model.Images, + ImdbId = model.ImdbId, + Ratings = model.Ratings, + Runtime = model.Runtime, + CleanTitle = model.CleanTitle, + Genres = model.Genres, + Year = model.Year + }; + } + } +} diff --git a/src/Radarr.Api.V3/Collections/CollectionResource.cs b/src/Radarr.Api.V3/Collections/CollectionResource.cs new file mode 100644 index 000000000..88b6b1bdf --- /dev/null +++ b/src/Radarr.Api.V3/Collections/CollectionResource.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaCover; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.Collections +{ + public class CollectionResource : RestResource + { + public CollectionResource() + { + Movies = new List(); + } + + public string Title { get; set; } + public string SortTitle { get; set; } + public int TmdbId { get; set; } + public List Images { get; set; } + public string Overview { get; set; } + public bool Monitored { get; set; } + public string RootFolderPath { get; set; } + public int QualityProfileId { get; set; } + public bool SearchOnAdd { get; set; } + public MovieStatusType MinimumAvailability { get; set; } + public List Movies { get; set; } + } + + public static class CollectionResourceMapper + { + public static CollectionResource ToResource(this MovieCollection model) + { + if (model == null) + { + return null; + } + + return new CollectionResource + { + Id = model.Id, + TmdbId = model.TmdbId, + Title = model.Title, + Overview = model.Overview, + SortTitle = model.SortTitle, + Monitored = model.Monitored, + Images = model.Images, + QualityProfileId = model.QualityProfileId, + RootFolderPath = model.RootFolderPath, + MinimumAvailability = model.MinimumAvailability, + SearchOnAdd = model.SearchOnAdd + }; + } + + public static List ToResource(this IEnumerable collections) + { + return collections.Select(ToResource).ToList(); + } + + public static MovieCollection ToModel(this CollectionResource resource) + { + if (resource == null) + { + return null; + } + + return new MovieCollection + { + Id = resource.Id, + Title = resource.Title, + TmdbId = resource.TmdbId, + SortTitle = resource.SortTitle, + Overview = resource.Overview, + Monitored = resource.Monitored, + QualityProfileId = resource.QualityProfileId, + RootFolderPath = resource.RootFolderPath, + SearchOnAdd = resource.SearchOnAdd, + MinimumAvailability = resource.MinimumAvailability + }; + } + + public static MovieCollection ToModel(this CollectionResource resource, MovieCollection collection) + { + var updatedmovie = resource.ToModel(); + + collection.ApplyChanges(updatedmovie); + + return collection; + } + } +} diff --git a/src/Radarr.Api.V3/Collections/CollectionUpdateCollectionResource.cs b/src/Radarr.Api.V3/Collections/CollectionUpdateCollectionResource.cs new file mode 100644 index 000000000..36fc06875 --- /dev/null +++ b/src/Radarr.Api.V3/Collections/CollectionUpdateCollectionResource.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Radarr.Api.V3.Collections +{ + public class CollectionUpdateCollectionResource + { + public int Id { get; set; } + public bool? Monitored { get; set; } + } +} diff --git a/src/Radarr.Api.V3/Collections/CollectionUpdateResource.cs b/src/Radarr.Api.V3/Collections/CollectionUpdateResource.cs new file mode 100644 index 000000000..05e269814 --- /dev/null +++ b/src/Radarr.Api.V3/Collections/CollectionUpdateResource.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; + +namespace Radarr.Api.V3.Collections +{ + public class CollectionUpdateResource + { + public List Collections { get; set; } + public bool? MonitorMovies { get; set; } + } +} diff --git a/src/Radarr.Api.V3/ImportLists/ImportListMoviesResource.cs b/src/Radarr.Api.V3/ImportLists/ImportListMoviesResource.cs index c4c71bb42..352e448b7 100644 --- a/src/Radarr.Api.V3/ImportLists/ImportListMoviesResource.cs +++ b/src/Radarr.Api.V3/ImportLists/ImportListMoviesResource.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using NzbDrone.Core.ImportLists.ImportListMovies; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using Radarr.Http.REST; namespace Radarr.Api.V3.ImportLists @@ -76,7 +77,7 @@ namespace Radarr.Api.V3.ImportLists Ratings = model.MovieMetadata.Value.Ratings, YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId, Studio = model.MovieMetadata.Value.Studio, - Collection = model.MovieMetadata.Value.Collection + Collection = model.MovieMetadata.Value.CollectionId }; } diff --git a/src/Radarr.Api.V3/ImportLists/ImportListResource.cs b/src/Radarr.Api.V3/ImportLists/ImportListResource.cs index 08019e064..a97f3007d 100644 --- a/src/Radarr.Api.V3/ImportLists/ImportListResource.cs +++ b/src/Radarr.Api.V3/ImportLists/ImportListResource.cs @@ -7,7 +7,7 @@ namespace Radarr.Api.V3.ImportLists { public bool Enabled { get; set; } public bool EnableAuto { get; set; } - public bool ShouldMonitor { get; set; } + public MonitorTypes Monitor { get; set; } public string RootFolderPath { get; set; } public int QualityProfileId { get; set; } public bool SearchOnAdd { get; set; } @@ -29,7 +29,7 @@ namespace Radarr.Api.V3.ImportLists resource.Enabled = definition.Enabled; resource.EnableAuto = definition.EnableAuto; - resource.ShouldMonitor = definition.ShouldMonitor; + resource.Monitor = definition.Monitor; resource.SearchOnAdd = definition.SearchOnAdd; resource.RootFolderPath = definition.RootFolderPath; resource.QualityProfileId = definition.ProfileId; @@ -51,7 +51,7 @@ namespace Radarr.Api.V3.ImportLists definition.Enabled = resource.Enabled; definition.EnableAuto = resource.EnableAuto; - definition.ShouldMonitor = resource.ShouldMonitor; + definition.Monitor = resource.Monitor; definition.SearchOnAdd = resource.SearchOnAdd; definition.RootFolderPath = resource.RootFolderPath; definition.ProfileId = resource.QualityProfileId; diff --git a/src/Radarr.Api.V3/Movies/MovieResource.cs b/src/Radarr.Api.V3/Movies/MovieResource.cs index 605c265c9..1e463713d 100644 --- a/src/Radarr.Api.V3/Movies/MovieResource.cs +++ b/src/Radarr.Api.V3/Movies/MovieResource.cs @@ -5,6 +5,7 @@ using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.Languages; using NzbDrone.Core.MediaCover; using NzbDrone.Core.Movies; +using NzbDrone.Core.Movies.Collections; using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Parser; using Radarr.Api.V3.MovieFiles; @@ -73,7 +74,7 @@ namespace Radarr.Api.V3.Movies public AddMovieOptions AddOptions { get; set; } public Ratings Ratings { get; set; } public MovieFileResource MovieFile { get; set; } - public MovieCollection Collection { get; set; } + public int CollectionId { get; set; } public float Popularity { get; set; } } @@ -140,7 +141,7 @@ namespace Radarr.Api.V3.Movies MovieFile = movieFile, YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId, Studio = model.MovieMetadata.Value.Studio, - Collection = model.MovieMetadata.Value.Collection, + Collection = model.MovieMetadata.Value.CollectionId, Popularity = model.MovieMetadata.Value.Popularity }; } diff --git a/yarn.lock b/yarn.lock index cd548cab8..6e8c3abf7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2222,7 +2222,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@2.3.1: +classnames@2.3.1, classnames@^2.2.5: version "2.3.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e" integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA== @@ -2828,6 +2828,11 @@ enhanced-resolve@^5.8.0: graceful-fs "^4.2.4" tapable "^2.2.0" +enquire.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/enquire.js/-/enquire.js-2.1.6.tgz#3e8780c9b8b835084c3f60e166dbc3c2a3c89814" + integrity sha1-PoeAybi4NQhMP2DhZtvDwqPImBQ= + enquirer@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" @@ -4287,6 +4292,13 @@ json-stringify-safe@^5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= +json2mq@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a" + integrity sha1-tje9O6nqvhIsg+lyBIOusQ0skEo= + dependencies: + string-convert "^0.2.0" + json5@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" @@ -5732,6 +5744,17 @@ react-side-effect@^1.0.2: dependencies: shallowequal "^1.0.1" +react-slick@0.28.1: + version "0.28.1" + resolved "https://registry.yarnpkg.com/react-slick/-/react-slick-0.28.1.tgz#12c18d991b59432df9c3757ba540a227b3fb85b9" + integrity sha512-JwRQXoWGJRbUTE7eZI1rGIHaXX/4YuwX6gn7ulfvUZ4vFDVQAA25HcsHSYaUiRCduTr6rskyIuyPMpuG6bbluw== + dependencies: + classnames "^2.2.5" + enquire.js "^2.1.6" + json2mq "^0.2.0" + lodash.debounce "^4.0.8" + resize-observer-polyfill "^1.5.0" + react-slider@1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/react-slider/-/react-slider-1.1.4.tgz#08b55f9be3e04cc10ae00cc3aedb6891dffe9bf3" @@ -6013,7 +6036,7 @@ reselect@4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== -resize-observer-polyfill@^1.4.1: +resize-observer-polyfill@^1.4.1, resize-observer-polyfill@^1.5.0: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== @@ -6294,6 +6317,11 @@ slice-ansi@^4.0.0: astral-regex "^2.0.0" is-fullwidth-code-point "^3.0.0" +slick-carousel@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/slick-carousel/-/slick-carousel-1.8.1.tgz#a4bfb29014887bb66ce528b90bd0cda262cc8f8d" + integrity sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -6427,6 +6455,11 @@ streamqueue@1.1.2: isstream "^0.1.2" readable-stream "^2.3.3" +string-convert@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97" + integrity sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c= + string-template@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"