New: Collections View

This commit is contained in:
Qstick 2022-03-07 20:03:00 -06:00
parent 10ebb33c9b
commit 8a3622fd82
124 changed files with 5830 additions and 422 deletions

View File

@ -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]'
}

View File

@ -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}
/>
<Route
path="/collections"
component={CollectionConnector}
/>
<Route
path="/add/import"
component={ImportMovies}

View File

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import AddNewCollectionMovieModalContentConnector from './AddNewCollectionMovieModalContentConnector';
function AddNewCollectionMovieModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<AddNewCollectionMovieModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
AddNewCollectionMovieModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default AddNewCollectionMovieModal;

View File

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

View File

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{title}
{
!title.contains(year) && !!year &&
<span className={styles.year}>({year})</span>
}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form>
<FormGroup>
<FormLabel>{translate('RootFolder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
movieFolder: folder,
isWindows
}}
selectedValueOptions={{
movieFolder: folder,
isWindows
}}
helpText={translate('SubfolderWillBeCreatedAutomaticallyInterp', [folder])}
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('Monitor')}
</FormLabel>
<FormInputGroup
type={inputTypes.MOVIE_MONITORED_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
onChange={onInputChange}
{...minimumAvailability}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingMovieLabelContainer}>
<span className={styles.searchForMissingMovieLabel}>
{translate('StartSearchForMissingMovie')}
</span>
<CheckInput
containerClassName={styles.searchForMissingMovieContainer}
className={styles.searchForMissingMovieInput}
name="searchForMovie"
onChange={onInputChange}
{...searchForMovie}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddMoviePress}
>
{translate('AddMovie')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
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;

View File

@ -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 (
<AddNewMovieModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddMoviePress={this.onAddMoviePress}
/>
);
}
}
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);

View File

@ -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 (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshCollections')}
iconName={icons.REFRESH}
isSpinning={isRefreshingCollections}
isDisabled={hasNoCollection}
onPress={this.onRefreshMovieCollectionsPress}
/>
<PageToolbarButton
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
iconName={icons.CHECK_SQUARE}
isDisabled={hasNoCollection}
onPress={this.onSelectAllPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'overview' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.OVERVIEW}
onPress={this.onOverviewOptionsPress}
/> :
null
}
{
(view === 'posters' || view === 'overview') &&
<PageToolbarSeparator />
}
<CollectionSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoCollection}
onSortSelect={onSortSelect}
/>
<CollectionFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoCollection}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>
{translate('UnableToLoadCollections')}
</div>
}
{
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
items={items}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectedChange={this.onSelectedChange}
onSelectAllChange={this.onSelectAllChange}
selectedState={selectedState}
{...otherProps}
/>
</div>
}
{
!error && isPopulated && !items.length &&
<NoCollection totalItems={totalItems} />
}
</PageContentBody>
{
isLoaded && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
{
isLoaded &&
<CollectionFooter
selectedIds={selectedMovieIds}
isSaving={isSaving}
isAdding={isAdding}
onUpdateSelectedPress={this.onUpdateSelectedPress}
/>
}
<CollectionOverviewOptionsModal
isOpen={isOverviewOptionsModalOpen}
onModalClose={this.onOverviewOptionsModalClose}
/>
</PageContent>
);
}
}
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;

View File

@ -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 (
<Collection
{...this.props}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onUpdateSelectedPress={this.onUpdateSelectedPress}
/>
);
}
}
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'
);

View File

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

View File

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

View File

@ -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 (
<PageContentFooter>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorCollection')}
isSaving={isSaving}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<CollectionFooterLabel
label={translate('MonitorMovies')}
isSaving={isSaving}
/>
<SelectInput
name="monitor"
value={monitor}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<CollectionFooterLabel
label={translate('CollectionsSelectedInterp', [selectedCount])}
isSaving={false}
/>
<div className={styles.buttons}>
<div>
<SpinnerButton
className={styles.addSelectedButton}
kind={kinds.PRIMARY}
isSpinning={isSaving}
isDisabled={!selectedCount || isSaving}
onPress={this.onUpdateSelectedPress}
>
{translate('UpdateSelected')}
</SpinnerButton>
</div>
</div>
</div>
</div>
</PageContentFooter>
);
}
}
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;

View File

@ -0,0 +1,8 @@
.label {
margin-bottom: 3px;
font-weight: bold;
}
.savingIcon {
margin-left: 8px;
}

View File

@ -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 (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
CollectionFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
CollectionFooterLabel.defaultProps = {
className: styles.label
};
export default CollectionFooterLabel;

View File

@ -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 (
<ItemComponent
{...otherProps}
id={id}
/>
);
}
}
CollectionItemConnector.propTypes = {
id: PropTypes.number,
component: PropTypes.elementType.isRequired
};
export default connect(createMapStateToProps)(CollectionItemConnector);

View File

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditCollectionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditCollectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditCollectionModal;

View File

@ -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 (
<EditCollectionModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditCollectionModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(EditCollectionModalConnector);

View File

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

View File

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('Edit')} - {title}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
!isSmallScreen &&
<div className={styles.poster}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.overview}>
{overview}
</div>
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>{translate('Monitored')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="monitored"
helpText={translate('MonitoredCollectionHelpText')}
{...monitored}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>
<FormInputGroup
type={inputTypes.AVAILABILITY_SELECT}
name="minimumAvailability"
{...minimumAvailability}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('QualityProfile')}</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
{...qualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Folder')}</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
{...rootFolderPath}
includeMissingValue={true}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="searchOnAdd"
helpText={translate('SearchOnAddCollectionHelpText')}
{...searchOnAdd}
onChange={onInputChange}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Cancel')}
</Button>
<SpinnerButton
isSpinning={isSaving}
onPress={this.onSavePress}
>
{translate('Save')}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
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;

View File

@ -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 (
<EditCollectionModalContent
{...this.props}
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
onMoveMoviePress={this.onMoveMoviePress}
/>
);
}
}
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);

View File

@ -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 (
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={CollectionFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
);
}
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;

View File

@ -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 (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<SortMenuItem
name="sortTitle"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
{translate('Title')}
</SortMenuItem>
</MenuContent>
</SortMenu>
);
}
CollectionSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
};
export default CollectionSortMenu;

View File

@ -0,0 +1,11 @@
.message {
margin-top: 10px;
margin-bottom: 30px;
text-align: center;
font-size: 20px;
}
.buttonContainer {
margin-top: 20px;
text-align: center;
}

View File

@ -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 (
<div>
<div className={styles.message}>
{translate('AllCollectionsHiddenDueToFilter')}
</div>
</div>
);
}
return (
<div>
<div className={styles.message}>
{translate('NoCollections')}
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
{translate('ImportExistingMovies')}
</Button>
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/new"
kind={kinds.PRIMARY}
>
{translate('AddNewMovie')}
</Button>
</div>
</div>
);
}
NoCollection.propTypes = {
totalItems: PropTypes.number.isRequired
};
export default NoCollection;

View File

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

View File

@ -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 (
<div className={styles.content}>
<div className={styles.posterContainer}>
{
isExistingMovie &&
<div className={styles.editorSelect}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={20}
onPress={onMonitorTogglePress}
/>
</div>
}
<Link
className={styles.link}
style={elementStyle}
{...linkProps}
>
<MoviePoster
className={styles.poster}
style={elementStyle}
images={images}
size={250}
lazy={false}
overflow={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
<div className={styles.overlay}>
<div className={styles.overlayTitle}>
{title}
</div>
{
id &&
<div className={styles.overlayStatus}>
<MovieIndexProgressBar
monitored={monitored}
hasFile={hasFile}
status={status}
bottomRadius={true}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
isAvailable={isAvailable}
/>
</div>
}
</div>
</Link>
</div>
<AddNewCollectionMovieModal
isOpen={isNewAddMovieModalOpen && !isExistingMovie}
tmdbId={tmdbId}
title={title}
year={year}
overview={overview}
images={images}
folder={folder}
onModalClose={this.onAddMovieModalClose}
collectionId={collectionId}
/>
<EditMovieModalConnector
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
</div>
);
}
}
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;

View File

@ -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 (
<CollectionMovie
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
/>
);
}
}
CollectionMovieConnector.propTypes = {
id: PropTypes.number,
monitored: PropTypes.bool,
toggleMovieMonitored: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CollectionMovieConnector);

View File

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

View File

@ -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 (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.editorSelect}>
<CheckInput
className={styles.checkInput}
name={id.toString()}
value={isSelected}
onChange={this.onChange}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={isSmallScreen ? 20 : 25}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title}>
{title}
</div>
<IconButton
name={icons.EDIT}
title={translate('EditCollection')}
onPress={this.onEditCollectionPress}
/>
</div>
<div className={styles.navigationButtons}>
<IconButton
name={icons.ARROW_LEFT}
title={translate('ScrollMovies')}
onPress={this.state.slider?.slickPrev}
size={20}
/>
<IconButton
name={icons.ARROW_RIGHT}
title={translate('ScrollMovies')}
onPress={this.state.slider?.slickNext}
size={20}
/>
</div>
</div>
{
showDetails &&
<div className={styles.defaults}>
<Label
className={styles.detailsLabel}
size={sizes.MEDIUM}
>
<Icon
name={icons.DRIVE}
size={13}
/>
<span className={styles.status}>
{`${missingMovies} missing movie(s)`}
</span>
</Label>
{
!isSmallScreen &&
<Label
className={styles.detailsLabel}
size={sizes.MEDIUM}
>
<Icon
name={icons.PROFILE}
size={13}
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</Label>
}
{
!isSmallScreen &&
<Label
className={styles.detailsLabel}
size={sizes.MEDIUM}
>
<Icon
name={icons.FOLDER}
size={13}
/>
<span className={styles.path}>
{rootFolderPath}
</span>
</Label>
}
{
!isSmallScreen &&
<Label
className={styles.detailsLabel}
size={sizes.MEDIUM}
>
<Icon
name={icons.PROFILE}
size={13}
/>
<span className={styles.genres}>
{genres.join(', ')}
</span>
</Label>
}
</div>
}
{
showOverview &&
<div className={styles.details}>
<div className={styles.overview}>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
</div>
}
<div className={styles.sliderContainer}>
<Slider ref={this.setSliderRef} {...sliderSettings}>
{movies.map((movie) => (
<div className={styles.movie} key={movie.tmdbId}>
<CollectionMovieConnector
key={movie.tmdbId}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
collectionId={id}
{...movie}
/>
</div>
))}
</Slider>
</div>
</div>
</div>
<EditCollectionModalConnector
isOpen={isEditCollectionModalOpen}
collectionId={id}
onModalClose={this.onEditCollectionModalClose}
/>
</div>
);
}
}
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;

View File

@ -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 (
<CollectionOverview
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
/>
);
}
}
CollectionOverviewConnector.propTypes = {
collectionId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
toggleCollectionMonitored: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(CollectionOverviewConnector);

View File

@ -0,0 +1,15 @@
.grid {
flex: 1 0 auto;
}
.container {
&:hover {
.content {
background-color: $tableRowHoverBackgroundColor;
}
}
}
.externalLinks {
margin-right: 0.5em;
}

View File

@ -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 (
<div
className={styles.container}
key={key}
style={style}
>
<CollectionItemConnector
key={collection.id}
component={CollectionOverviewConnector}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
rowHeight={rowHeight}
overviewOptions={overviewOptions}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
collectionId={collection.id}
isSelected={selectedState[collection.id]}
onSelectedChange={onSelectedChange}
/>
</div>
);
}
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
}
//
// Render
render() {
const {
isSmallScreen,
scroller,
items,
selectedState
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
selectedState={selectedState}
scrollToAlignment={'start'}
isScrollingOptout={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
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;

View File

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

View File

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<CollectionOverviewOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
CollectionOverviewOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default CollectionOverviewOptionsModal;

View File

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Overview Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>{translate('PosterSize')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('DetailedProgressBar')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowCollectionDetails')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showDetails"
value={showDetails}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('ShowOverview')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showOverview"
value={showOverview}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,10 @@ const links = [
title: translate('ImportLibrary'),
to: '/add/import'
},
{
title: translate('Collections'),
to: '/collections'
},
{
title: translate('Discover'),
to: '/add/discover'

View File

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

View File

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

View File

@ -164,7 +164,7 @@ class DiscoverMovieRow extends Component {
key={name}
className={styles[name]}
>
{collection ? collection.name : null }
{collection ? collection.title : null }
</VirtualTableRowCell>
);
}
@ -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,

View File

@ -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 {
</InfoLabel>
{
!!collection &&
!!collectionId &&
<InfoLabel
className={styles.detailsInfoLabel}
title={translate('Collection')}
size={sizes.LARGE}
>
<div className={styles.collection}>
<MovieCollectionConnector
tmdbId={collection.tmdbId}
name={collection.name}
movieId={id}
<MovieCollectionLabelConnector
collectionId={collectionId}
/>
</div>
</InfoLabel>
@ -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,

View File

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

View File

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

View File

@ -15,6 +15,7 @@ function MovieIndexProgressBar(props) {
isAvailable,
posterWidth,
detailedProgressBar,
bottomRadius,
queueStatus,
queueState
} = props;
@ -40,7 +41,7 @@ function MovieIndexProgressBar(props) {
return (
<ProgressBar
className={styles.progressBar}
containerClassName={styles.progress}
containerClassName={bottomRadius ? styles.progressRadius : styles.progress}
progress={progress}
kind={getStatusStyle(status, monitored, hasFile, isAvailable, 'kinds', queueStatusText)}
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
@ -54,6 +55,7 @@ function MovieIndexProgressBar(props) {
MovieIndexProgressBar.propTypes = {
monitored: PropTypes.bool.isRequired,
hasFile: PropTypes.bool.isRequired,
bottomRadius: PropTypes.bool,
isAvailable: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
posterWidth: PropTypes.number.isRequired,
@ -62,4 +64,8 @@ MovieIndexProgressBar.propTypes = {
queueState: PropTypes.string
};
MovieIndexProgressBar.defaultProps = {
bottomRadius: false
};
export default MovieIndexProgressBar;

View File

@ -170,7 +170,7 @@ class MovieIndexRow extends Component {
key={name}
className={styles[name]}
>
{collection ? collection.name : null }
{collection ? collection.title : null }
</VirtualTableRowCell>
);
}

View File

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

View File

@ -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 (
<div>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={15}
onPress={this.onAddImportListPress}
/>
{name}
<EditImportListModalConnector
id={importListId}
isOpen={this.state.isEditImportListModalOpen}
onModalClose={this.onEditImportListModalClose}
onDeleteImportListPress={this.onDeleteImportListPress}
/>
</div>
);
}
}
MovieCollection.propTypes = {
tmdbId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
collectionList: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired
};
export default MovieCollection;

View File

@ -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 (
<MovieCollection
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
/>
);
}
}
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);

View File

@ -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 (
<div>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
size={15}
onPress={onMonitorTogglePress}
/>
{title}
</div>
);
}
}
MovieCollectionLabel.propTypes = {
title: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired
};
export default MovieCollectionLabel;

View File

@ -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 (
<MovieCollectionLabel
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
/>
);
}
}
MovieCollectionLabelConnector.propTypes = {
collectionId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
toggleCollectionMonitored: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieCollectionLabelConnector);

View File

@ -42,7 +42,7 @@ function EditImportListModalContent(props) {
name,
enabled,
enableAuto,
shouldMonitor,
monitor,
minimumAvailability,
qualityProfileId,
rootFolderPath,
@ -121,31 +121,28 @@ function EditImportListModalContent(props) {
</FormGroup>
<FormGroup>
<FormLabel>{translate('AddMoviesMonitored')}</FormLabel>
<FormLabel>{translate('Monitor')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="shouldMonitor"
type={inputTypes.MOVIE_MONITORED_SELECT}
name="monitor"
helpText={translate('ShouldMonitorHelpText')}
{...shouldMonitor}
{...monitor}
onChange={onInputChange}
/>
</FormGroup>
{
shouldMonitor &&
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>
<FormGroup>
<FormLabel>{translate('SearchOnAdd')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="searchOnAdd"
helpText={translate('SearchOnAddHelpText')}
{...searchOnAdd}
onChange={onInputChange}
/>
</FormGroup>
}
<FormInputGroup
type={inputTypes.CHECK}
name="searchOnAdd"
helpText={translate('SearchOnAddHelpText')}
{...searchOnAdd}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('MinimumAvailability')}</FormLabel>

View File

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

View File

@ -30,7 +30,7 @@ export const defaultState = {
defaults: {
rootFolderPath: '',
monitor: 'true',
monitor: 'movieOnly',
qualityProfileId: 0,
minimumAvailability: 'announced',
searchForMovie: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,14 @@ function createUnoptimizedSelector(uiSection) {
const items = movies.items.map((s) => {
const {
id,
sortTitle
sortTitle,
collectionId
} = s;
return {
id,
sortTitle
sortTitle,
collectionId
};
});

View File

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

View File

@ -1,6 +1,7 @@
const scrollPositions = {
movieIndex: 0,
discoverMovie: 0
discoverMovie: 0,
movieCollections: 0
};
export default scrollPositions;

View File

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

View File

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

View File

@ -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",

View File

@ -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<collections>
{
[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<Collection207>("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<Movie207>("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<Collection207>("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<ListDefinition207>("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<ListDefinition207>("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<ListDefinition207>("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<Collection207>("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; }
}
}

View File

@ -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<CleanupOrphanedCollections, MovieCollection>
{
[Test]
public void should_delete_orphaned_collection_item()
{
var collection = Builder<MovieCollection>.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<MovieCollection>.CreateNew()
.With(h => h.Id = 3)
.With(h => h.Title = "Some Credit")
.BuildNew();
Db.Insert(collection);
var movie = Builder<Movie>.CreateNew().With(m => m.CollectionId = collection.Id).BuildNew();
Db.Insert(movie);
Subject.Clean();
AllStoredModels.Should().HaveCount(1);
}
}
}

View File

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

View File

@ -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<IProfileRepository>().Verify(c => c.Delete(It.IsAny<int>()), Times.Never());
}
[Test]
public void should_not_be_able_to_delete_profile_if_assigned_to_collection()
{
var movieList = Builder<Movie>.CreateListOfSize(3)
.All()
.With(c => c.ProfileId = 1)
.Build().ToList();
var importList = Builder<ImportListDefinition>.CreateListOfSize(3)
.Random(1)
.With(c => c.ProfileId = 1)
.Build().ToList();
var collectionList = Builder<MovieCollection>.CreateListOfSize(3)
.All()
.With(c => c.QualityProfileId = 2)
.Build().ToList();
Mocker.GetMock<IMovieService>().Setup(c => c.GetAllMovies()).Returns(movieList);
Mocker.GetMock<IImportListFactory>().Setup(c => c.All()).Returns(importList);
Mocker.GetMock<IMovieCollectionService>().Setup(c => c.GetAllCollections()).Returns(collectionList);
Assert.Throws<ProfileInUseException>(() => Subject.Delete(2));
Mocker.GetMock<IProfileRepository>().Verify(c => c.Delete(It.IsAny<int>()), 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<MovieCollection>.CreateListOfSize(3)
.All()
.With(c => c.QualityProfileId = 2)
.Build().ToList();
Mocker.GetMock<IMovieService>().Setup(c => c.GetAllMovies()).Returns(movieList);
Mocker.GetMock<IImportListFactory>().Setup(c => c.All()).Returns(importList);
Mocker.GetMock<IMovieCollectionService>().Setup(c => c.GetAllCollections()).Returns(collectionList);
Subject.Delete(1);

View File

@ -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.
/// </summary>
/// <typeparam name="TChild"></typeparam>
[JsonConverter(typeof(LazyLoadedConverterFactory))]
public class LazyLoaded<TChild> : ILazyLoaded
{
protected TChild _value;
@ -62,11 +64,6 @@ namespace NzbDrone.Core.Datastore
{
return MemberwiseClone();
}
public bool ShouldSerializeValue()
{
return IsLoaded;
}
}
/// <summary>

View File

@ -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<TChild> : JsonConverter<LazyLoaded<TChild>>
{
private readonly JsonConverter<TChild> _childConverter;
private readonly Type _childType;
public LazyLoadedConverter(JsonSerializerOptions options)
{
// For performance, use the existing converter if available.
_childConverter = (JsonConverter<TChild>)options
.GetConverter(typeof(TChild));
// Cache the type.
_childType = typeof(TChild);
}
public override LazyLoaded<TChild> 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<TChild>(ref reader, options);
}
if (value != null)
{
return new LazyLoaded<TChild>(value);
}
else
{
return null;
}
}
public override void Write(Utf8JsonWriter writer, LazyLoaded<TChild> 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();
}
}
}
}
}

View File

@ -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<string>();
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<int>();
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<MovieCollection206>(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<TmdbCollectionSettings206>(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<MovieCollection207>();
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<MovieCollection206>(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; }
}
}
}

View File

@ -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>("MovieMetadata").RegisterModel()
.Ignore(s => s.Translations);
Mapper.Entity<MovieCollection>("Collections").RegisterModel();
}
private static void RegisterMappers()

View File

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

View File

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

View File

@ -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<string, List<MovieCollection>>();
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<MovieCollection> { 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<MovieCollection> collections)
{
return $"{rootFolderPath} ({string.Join(", ", collections.Select(l => l.Title))})";
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ImportListMovie> ParseResponse(ImportListResponse importResponse)
{
var movies = new List<ImportListMovie>();
if (!PreProcess(importResponse))
{
return movies;
}
var jsonResponse = JsonConvert.DeserializeObject<CollectionResponseResource>(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;
}
}
}

View File

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

View File

@ -1,28 +0,0 @@
using System.Text.RegularExpressions;
using FluentValidation;
using NzbDrone.Core.Annotations;
namespace NzbDrone.Core.ImportLists.TMDb.Collection
{
public class TMDbCollectionSettingsValidator : TMDbSettingsBaseValidator<TMDbCollectionSettings>
{
public TMDbCollectionSettingsValidator()
: base()
{
RuleFor(c => c.CollectionId).Matches(@"^[1-9][0-9]*$", RegexOptions.IgnoreCase);
}
}
public class TMDbCollectionSettings : TMDbSettingsBase<TMDbCollectionSettings>
{
protected override AbstractValidator<TMDbCollectionSettings> 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; }
}
}

View File

@ -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(),

View File

@ -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",

View File

@ -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<MovieMetadata, List<Credit>> GetMovieInfo(int tmdbId);
MovieCollection GetCollectionInfo(int tmdbId);
List<MovieMetadata> GetBulkMovieInfo(List<int> tmdbIds);
HashSet<int> GetChangedMovies(DateTime startTime);

View File

@ -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<ImageResource> Images { get; set; }
public List<MovieResource> Parts { get; set; }
}
}

View File

@ -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<MovieMetadata, List<Credit>>(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<CollectionResource>(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<MovieMetadata> GetBulkMovieInfo(List<int> 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<MediaCover.MediaCover>(),
Movies = arg.Parts?.Select(x => MapCollectionMovie(x)).ToList() ?? new List<CollectionMovie>()
};
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

View File

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

View File

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

View File

@ -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<MediaCover.MediaCover> Images { get; set; }
public int Year { get; set; }
public Ratings Ratings { get; set; }
public List<string> Genres { get; set; }
}
}

View File

@ -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<MediaCover.MediaCover>();
Movies = new List<CollectionMovie>();
}
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<MediaCover.MediaCover> Images { get; set; }
public DateTime Added { get; set; }
public List<CollectionMovie> 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;
}
}
}

View File

@ -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<CollectionAddedEvent>
{
private readonly IManageCommandQueue _commandQueueManager;
public MovieCollectionAddedHandler(IManageCommandQueue commandQueueManager)
{
_commandQueueManager = commandQueueManager;
}
public void Handle(CollectionAddedEvent message)
{
_commandQueueManager.Push(new RefreshCollectionsCommand(new List<int> { message.Collection.Id }));
}
}
}

View File

@ -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<MovieCollection>
{
public MovieCollection GetByTmdbId(int tmdbId);
bool UpsertMany(List<MovieCollection> data);
}
public class MovieCollectionRepository : BasicRepository<MovieCollection>, IMovieCollectionRepository
{
public MovieCollectionRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public MovieCollection GetByTmdbId(int tmdbId)
{
return Query(x => x.TmdbId == tmdbId).FirstOrDefault();
}
public List<MovieCollection> GetByTmdbId(List<int> tmdbIds)
{
return Query(x => Enumerable.Contains(tmdbIds, x.TmdbId));
}
public bool UpsertMany(List<MovieCollection> data)
{
var existingMetadata = GetByTmdbId(data.Select(x => x.TmdbId).ToList());
var updateCollectionList = new List<MovieCollection>();
var addCollectionList = new List<MovieCollection>();
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;
}
}
}

View File

@ -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<MovieCollection> GetCollections(IEnumerable<int> ids);
List<MovieCollection> GetAllCollections();
MovieCollection UpdateCollection(MovieCollection collection);
void RemoveCollection(MovieCollection collection);
bool Upsert(MovieCollection collection);
bool UpsertMany(List<MovieCollection> collections);
}
public class MovieCollectionService : IMovieCollectionService, IHandleAsync<MoviesDeletedEvent>
{
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<MovieCollection> GetCollections(IEnumerable<int> ids)
{
return _repo.Get(ids);
}
public List<MovieCollection> 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<MovieCollection> { collection });
}
public bool UpsertMany(List<MovieCollection> 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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More