From d8c89f5bbdea6f2b1db81c51802c26d8dfdfcac9 Mon Sep 17 00:00:00 2001 From: Qstick Date: Fri, 29 Dec 2017 22:23:04 -0500 Subject: [PATCH] UI Updates (Cancel Import, Move Artist, Manual Import from Artist) Ability to cancel an import lookup/search at any point. Ability to move artist path from Artist Edit or bulk move from Mass Editor. Trigger manual import for Artist path from Artist Detail page. Pulled from Sonarr --- frontend/src/Activity/History/HistoryRow.js | 6 ++ frontend/src/Activity/activity.less | 27 ------ .../Import/ImportArtistFooter.css | 6 ++ .../ImportArtist/Import/ImportArtistFooter.js | 26 ++++-- .../Import/ImportArtistFooterConnector.js | 19 +++-- .../ImportArtist/Import/ImportArtistTable.js | 20 +---- .../SelectArtist/ImportArtistSelectArtist.css | 3 +- .../SelectArtist/ImportArtistSelectArtist.js | 26 ++++-- .../ImportArtistSelectArtistConnector.js | 11 ++- frontend/src/Album/EpisodeLanguage.js | 16 +++- frontend/src/Album/EpisodeNumber.js | 3 +- frontend/src/Album/History/AlbumHistory.js | 5 ++ frontend/src/Album/History/AlbumHistoryRow.js | 13 +++ frontend/src/Artist/Delete/DeleteArtist.less | 39 --------- frontend/src/Artist/Details/ArtistDetails.js | 24 ++++++ .../src/Artist/Edit/EditArtistModalContent.js | 51 +++++++++++- .../Edit/EditArtistModalContentConnector.js | 21 +++-- .../Artist/Editor/ArtistEditorConnector.js | 34 +++++--- .../src/Artist/Editor/ArtistEditorFooter.js | 44 +++++++++- .../History/ArtistHistoryModalContent.js | 5 ++ .../src/Artist/History/ArtistHistoryRow.js | 12 +++ .../Artist/Index/ArtistIndexItemConnector.js | 11 +-- .../Index/Banners/ArtistIndexBanner.css | 3 +- .../Artist/Index/Banners/ArtistIndexBanner.js | 9 ++ .../Index/Banners/ArtistIndexBanners.js | 12 ++- .../ArtistIndexBannerOptionsModalContent.js | 34 ++++++-- ...IndexBannerOptionsModalContentConnector.js | 2 +- .../Index/Overview/ArtistIndexOverview.js | 2 + .../Index/Overview/ArtistIndexOverviewInfo.js | 31 +++++-- .../Index/Overview/ArtistIndexOverviews.js | 5 +- .../ArtistIndexOverviewOptionsModalContent.js | 20 +++++ .../Index/Posters/ArtistIndexPoster.css | 3 +- .../Artist/Index/Posters/ArtistIndexPoster.js | 9 ++ .../Index/Posters/ArtistIndexPosters.js | 12 ++- .../ArtistIndexPosterOptionsModalContent.js | 20 +++++ .../Artist/Index/Table/ArtistIndexTable.js | 45 +++------- .../src/Artist/MoveArtist/MoveArtistModal.css | 5 ++ .../src/Artist/MoveArtist/MoveArtistModal.js | 83 +++++++++++++++++++ frontend/src/Commands/commandNames.js | 1 + .../Components/Form/EnhancedSelectInput.css | 7 ++ .../Components/Form/EnhancedSelectInput.js | 9 +- .../Form/EnhancedSelectInputSelectedValue.css | 4 + .../Form/EnhancedSelectInputSelectedValue.js | 16 +++- frontend/src/Components/Modal/Modal.js | 17 +++- frontend/src/Components/Modal/ModalContent.js | 26 +++--- .../Components/Page/Sidebar/PageSidebar.js | 2 +- frontend/src/Components/Table/VirtualTable.js | 11 --- .../InteractiveImportModalContent.css | 11 +++ .../InteractiveImportModalContent.js | 63 +++++++++++++- .../InteractiveImportModalContentConnector.js | 50 ++++++++++- .../Track/SelectTrackModalContent.js | 9 +- .../InteractiveImport/Track/SelectTrackRow.js | 6 ++ .../MediaManagement/MediaManagement.js | 4 +- .../Creators/createRemoveItemHandler.js | 4 +- .../Creators/createSaveProviderHandler.js | 13 ++- frontend/src/Store/Actions/artistActions.js | 50 ++++++++--- .../src/Store/Actions/artistEditorActions.js | 2 +- .../src/Store/Actions/artistIndexActions.js | 3 + .../src/Store/Actions/importArtistActions.js | 82 ++++++++++++++---- frontend/src/Store/Actions/tagActions.js | 28 +++---- frontend/src/Store/thunks.js | 9 +- frontend/src/Styles/Variables/colors.js | 1 + .../Artist/ArtistEditorModule.cs | 23 ++++- .../Artist/ArtistEditorResource.cs | 1 + src/Lidarr.Api.V1/Artist/ArtistModule.cs | 24 +++++- src/Lidarr.Api.V1/History/HistoryModule.cs | 6 +- src/Lidarr.Api.V1/History/HistoryResource.cs | 1 + .../ManualImport/ManualImportModule.cs | 5 +- .../TrackFiles/TrackFileResource.cs | 10 +-- .../MusicTests/MoveArtistServiceFixture.cs | 68 +++++++++------ .../DecisionEngine/UpgradableSpecification.cs | 45 +++++++--- .../Commands/RenameArtistCommand.cs | 5 +- .../TrackImport/ImportDecisionMaker.cs | 13 ++- .../TrackImport/Manual/ManualImportService.cs | 10 +-- src/NzbDrone.Core/Music/ArtistService.cs | 7 +- .../Music/Commands/BulkMoveArtistCommand.cs | 19 +++++ .../Music/Commands/MoveArtistCommand.cs | 5 +- src/NzbDrone.Core/Music/MoveArtistService.cs | 63 +++++++++----- src/NzbDrone.Core/NzbDrone.Core.csproj | 1 + 79 files changed, 1075 insertions(+), 376 deletions(-) delete mode 100644 frontend/src/Activity/activity.less delete mode 100644 frontend/src/Artist/Delete/DeleteArtist.less create mode 100644 frontend/src/Artist/MoveArtist/MoveArtistModal.css create mode 100644 frontend/src/Artist/MoveArtist/MoveArtistModal.js create mode 100644 src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index cf48c36d2..3eb438e75 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -58,7 +58,9 @@ class HistoryRow extends Component { album, track, language, + languageCutoffNotMet, quality, + qualityCutoffNotMet, eventType, sourceTitle, date, @@ -135,6 +137,7 @@ class HistoryRow extends Component { ); @@ -145,6 +148,7 @@ class HistoryRow extends Component { ); @@ -233,7 +237,9 @@ HistoryRow.propTypes = { album: PropTypes.object, track: PropTypes.object, language: PropTypes.object.isRequired, + languageCutoffNotMet: PropTypes.bool.isRequired, quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, eventType: PropTypes.string.isRequired, sourceTitle: PropTypes.string.isRequired, date: PropTypes.string.isRequired, diff --git a/frontend/src/Activity/activity.less b/frontend/src/Activity/activity.less deleted file mode 100644 index c6d9b6d2a..000000000 --- a/frontend/src/Activity/activity.less +++ /dev/null @@ -1,27 +0,0 @@ - -.queue-status-cell .popover { - max-width: 800px; -} - -.queue { - .protocol-cell { - text-align: center; - width: 80px; - } - - .episode-number-cell { - min-width: 90px; - } -} - -.remove-from-queue-modal { - .form-horizontal { - margin-top: 20px; - } -} - -.history-detail-modal { - .info { - word-wrap: break-word; - } -} diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css index 1df1b8c90..0a61ca509 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.css @@ -19,6 +19,12 @@ height: 35px; } +.loadingButton { + composes: importButton; + + margin-left: 10px; +} + .loading { composes: loading from 'Components/Loading/LoadingIndicator.css'; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js index 2e4440620..6cae9f6e2 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; import SpinnerButton from 'Components/Link/SpinnerButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import CheckInput from 'Components/Form/CheckInput'; @@ -117,7 +118,8 @@ class ImportArtistFooter extends Component { isMetadataProfileIdMixed, showLanguageProfile, showMetadataProfile, - onImportPress + onImportPress, + onCancelLookupPress } = this.props; const { @@ -227,10 +229,21 @@ class ImportArtistFooter extends Component { { isLookingUpArtist && - + + } + + { + isLookingUpArtist && + } { @@ -261,7 +274,8 @@ ImportArtistFooter.propTypes = { showLanguageProfile: PropTypes.bool.isRequired, showMetadataProfile: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, - onImportPress: PropTypes.func.isRequired + onImportPress: PropTypes.func.isRequired, + onCancelLookupPress: PropTypes.func.isRequired }; export default ImportArtistFooter; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js index bf3c901ec..ede45dffd 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import ImportArtistFooter from './ImportArtistFooter'; +import { cancelLookupArtist } from 'Store/Actions/importArtistActions'; function isMixed(items, selectedIds, defaultValue, key) { return _.some(items, (artist) => { @@ -23,11 +24,11 @@ function createMapStateToProps() { albumFolder: defaultAlbumFolder } = addArtist.defaults; - const items = importArtist.items; - - const isLookingUpArtist = _.some(importArtist.items, (artist) => { - return !artist.isPopulated && artist.error == null; - }); + const { + isLookingUpArtist, + isImporting, + items + } = importArtist; const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); @@ -37,8 +38,8 @@ function createMapStateToProps() { return { selectedCount: selectedIds.length, - isImporting: importArtist.isImporting, isLookingUpArtist, + isImporting, defaultMonitor, defaultQualityProfileId, defaultLanguageProfileId, @@ -54,4 +55,8 @@ function createMapStateToProps() { ); } -export default connect(createMapStateToProps)(ImportArtistFooter); +const mapDispatchToProps = { + onCancelLookupPress: cancelLookupArtist +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js index f87164a1f..8a7de50b3 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistTable.js @@ -10,12 +10,6 @@ class ImportArtistTable extends Component { // // Lifecycle - constructor(props, context) { - super(props, context); - - this._table = null; - } - componentDidMount() { const { unmappedFolders, @@ -101,22 +95,11 @@ class ImportArtistTable extends Component { return; } }); - - // Forces the table to re-render if the selected state - // has changed otherwise it will be stale. - - if (prevProps.selectedState !== selectedState && this._table) { - this._table.forceUpdateGrid(); - } } // // Control - setTableRef = (ref) => { - this._table = ref; - } - rowRenderer = ({ key, rowIndex, style }) => { const { rootFolderId, @@ -156,6 +139,7 @@ class ImportArtistTable extends Component { showLanguageProfile, showMetadataProfile, scrollTop, + selectedState, onSelectAllChange, onScroll } = this.props; @@ -166,7 +150,6 @@ class ImportArtistTable extends Component { return ( } + selectedState={selectedState} onScroll={onScroll} /> ); diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css index 4d9159a70..c5023c00d 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.css @@ -66,6 +66,5 @@ .searchInput { composes: text from 'Components/Form/TextInput.css'; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-radius: 0; } diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js index e0c883449..533a0bc46 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtist.js @@ -5,6 +5,7 @@ import TetherComponent from 'react-tether'; import { icons, kinds } from 'Helpers/Props'; import Icon from 'Components/Icon'; import SpinnerIcon from 'Components/SpinnerIcon'; +import FormInputButton from 'Components/Form/FormInputButton'; import Link from 'Components/Link/Link'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import TextInput from 'Components/Form/TextInput'; @@ -99,6 +100,10 @@ class ImportArtistSelectArtist extends Component { }); } + onRefreshPress = () => { + this.props.onSearchInputChange(this.state.term); + } + onArtistSelect = (foreignArtistId) => { this.setState({ isOpen: false }); @@ -116,7 +121,8 @@ class ImportArtistSelectArtist extends Component { isPopulated, error, items, - queued + queued, + isLookingUpArtist } = this.props; const errorMessage = error && @@ -137,7 +143,7 @@ class ImportArtistSelectArtist extends Component { onPress={this.onPress} > { - queued && !isPopulated && + isLookingUpArtist && queued && !isPopulated &&
- +
+ + + +
@@ -253,6 +266,7 @@ ImportArtistSelectArtist.propTypes = { error: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, queued: PropTypes.bool.isRequired, + isLookingUpArtist: PropTypes.bool.isRequired, onSearchInputChange: PropTypes.func.isRequired, onArtistSelect: PropTypes.func.isRequired }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js index 21662faa7..21e2bcab2 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js +++ b/frontend/src/AddArtist/ImportArtist/Import/SelectArtist/ImportArtistSelectArtistConnector.js @@ -9,9 +9,13 @@ import ImportArtistSelectArtist from './ImportArtistSelectArtist'; function createMapStateToProps() { return createSelector( + (state) => state.importArtist.isLookingUpArtist, createImportArtistItemSelector(), - (item) => { - return item; + (isLookingUpArtist, item) => { + return { + isLookingUpArtist, + ...item + }; } ); } @@ -29,7 +33,8 @@ class ImportArtistSelectArtistConnector extends Component { onSearchInputChange = (term) => { this.props.queueLookupArtist({ name: this.props.id, - term + term, + topOfQueue: true }); } diff --git a/frontend/src/Album/EpisodeLanguage.js b/frontend/src/Album/EpisodeLanguage.js index 7a5a24963..52c8b3390 100644 --- a/frontend/src/Album/EpisodeLanguage.js +++ b/frontend/src/Album/EpisodeLanguage.js @@ -1,11 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import Label from 'Components/Label'; +import { kinds } from 'Helpers/Props'; function EpisodeLanguage(props) { const { className, - language + language, + isCutoffNotMet } = props; if (!language) { @@ -13,7 +15,10 @@ function EpisodeLanguage(props) { } return ( -
} + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + { showQualityProfile &&
@@ -214,6 +222,7 @@ ArtistIndexBanner.propTypes = { bannerHeight: PropTypes.number.isRequired, detailedProgressBar: PropTypes.bool.isRequired, showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, showQualityProfile: PropTypes.bool.isRequired, qualityProfile: PropTypes.object.isRequired, showRelativeDates: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js index 986632ab4..12196962a 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js @@ -39,6 +39,7 @@ function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) const { detailedProgressBar, showTitle, + showMonitored, showQualityProfile } = bannerOptions; @@ -55,6 +56,10 @@ function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) heights.push(19); } + if (showMonitored) { + heights.push(19); + } + if (showQualityProfile) { heights.push(19); } @@ -213,6 +218,7 @@ class ArtistIndexBanners extends Component { const { detailedProgressBar, showTitle, + showMonitored, showQualityProfile } = bannerOptions; @@ -231,12 +237,16 @@ class ArtistIndexBanners extends Component { bannerHeight={bannerHeight} detailedProgressBar={detailedProgressBar} showTitle={showTitle} + showMonitored={showMonitored} showQualityProfile={showQualityProfile} showRelativeDates={showRelativeDates} shortDateFormat={shortDateFormat} timeFormat={timeFormat} style={style} - {...artist} + artistId={artist.id} + languageProfileId={artist.languageProfileId} + qualityProfileId={artist.qualityProfileId} + metadataProfileId={artist.metadataProfileId} /> ); } diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js index d320acea5..c3d9b321a 100644 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js @@ -30,6 +30,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { detailedProgressBar: props.detailedProgressBar, size: props.size, showTitle: props.showTitle, + showMonitored: props.showMonitored, showQualityProfile: props.showQualityProfile }; } @@ -39,6 +40,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { detailedProgressBar, size, showTitle, + showMonitored, showQualityProfile } = this.props; @@ -56,6 +58,10 @@ class ArtistIndexBannerOptionsModalContent extends Component { state.showTitle = showTitle; } + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + if (showQualityProfile !== prevProps.showQualityProfile) { state.showQualityProfile = showQualityProfile; } @@ -68,11 +74,11 @@ class ArtistIndexBannerOptionsModalContent extends Component { // // Listeners - onChangeOption = ({ name, value }) => { + onChangeBannerOption = ({ name, value }) => { this.setState({ [name]: value }, () => { - this.props.onChangeOption({ [name]: value }); + this.props.onChangeBannerOption({ [name]: value }); }); } @@ -88,6 +94,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { detailedProgressBar, size, showTitle, + showMonitored, showQualityProfile } = this.state; @@ -107,7 +114,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { name="size" value={size} values={bannerSizeOptions} - onChange={this.onChangeOption} + onChange={this.onChangeBannerOption} /> @@ -119,7 +126,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { name="detailedProgressBar" value={detailedProgressBar} helpText="Show text on progess bar" - onChange={this.onChangeOption} + onChange={this.onChangeBannerOption} /> @@ -131,7 +138,19 @@ class ArtistIndexBannerOptionsModalContent extends Component { name="showTitle" value={showTitle} helpText="Show artist name under banner" - onChange={this.onChangeOption} + onChange={this.onChangeBannerOption} + /> + + + + Show Monitored + + @@ -143,7 +162,7 @@ class ArtistIndexBannerOptionsModalContent extends Component { name="showQualityProfile" value={showQualityProfile} helpText="Show quality profile under banner" - onChange={this.onChangeOption} + onChange={this.onChangeBannerOption} /> @@ -166,7 +185,8 @@ ArtistIndexBannerOptionsModalContent.propTypes = { showTitle: PropTypes.bool.isRequired, showQualityProfile: PropTypes.bool.isRequired, detailedProgressBar: PropTypes.bool.isRequired, - onChangeOption: PropTypes.func.isRequired, + onChangeBannerOption: PropTypes.func.isRequired, + showMonitored: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js index 0ea742781..884edd05d 100644 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js @@ -14,7 +14,7 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { - onChangeOption(payload) { + onChangeBannerOption(payload) { dispatch(setArtistBannerOption(payload)); } }; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js index 059e3c63d..12b9fc04a 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js @@ -5,6 +5,7 @@ import { icons } from 'Helpers/Props'; import dimensions from 'Styles/Variables/dimensions'; import fonts from 'Styles/Variables/fonts'; import IconButton from 'Components/Link/IconButton'; +import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import ArtistPoster from 'Artist/ArtistPoster'; @@ -188,6 +189,7 @@ class ArtistIndexOverview extends Component { @@ -77,7 +80,23 @@ function ArtistIndexOverviewInfo(props) { } { - isVisible('qualityProfileId', showQualityProfile, qualityProfile, sortKey) && maxRows > 1 && + isVisible('monitored', showMonitored, monitored, sortKey) && maxRows > 1 && +
+ + + {monitoredText} +
+ } + + { + isVisible('qualityProfileId', showQualityProfile, qualityProfile, sortKey) && maxRows > 2 &&
2 && + isVisible('added', showAdded, added, sortKey) && maxRows > 3 &&
3 && + isVisible('albumCount', showAlbumCount, albumCount, sortKey) && maxRows > 4 &&
4 && + isVisible('path', showPath, path, sortKey) && maxRows > 5 &&
5 && + isVisible('sizeOnDisk', showSizeOnDisk, sizeOnDisk, sortKey) && maxRows > 6 &&
); } diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js index b5bf02b45..6ae1d8993 100644 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js @@ -29,6 +29,7 @@ class ArtistIndexOverviewOptionsModalContent extends Component { this.state = { detailedProgressBar: props.detailedProgressBar, size: props.size, + showMonitored: props.showMonitored, showQualityProfile: props.showQualityProfile, showPreviousAiring: props.showPreviousAiring, showAdded: props.showAdded, @@ -42,6 +43,7 @@ class ArtistIndexOverviewOptionsModalContent extends Component { const { detailedProgressBar, size, + showMonitored, showQualityProfile, showPreviousAiring, showAdded, @@ -60,6 +62,10 @@ class ArtistIndexOverviewOptionsModalContent extends Component { state.size = size; } + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + if (showQualityProfile !== prevProps.showQualityProfile) { state.showQualityProfile = showQualityProfile; } @@ -111,6 +117,7 @@ class ArtistIndexOverviewOptionsModalContent extends Component { const { detailedProgressBar, size, + showMonitored, showQualityProfile, showPreviousAiring, showAdded, @@ -152,6 +159,18 @@ class ArtistIndexOverviewOptionsModalContent extends Component { + Show Monitored + + + + + + Show Quality Profile } + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + { showQualityProfile &&
@@ -214,6 +222,7 @@ ArtistIndexPoster.propTypes = { posterHeight: PropTypes.number.isRequired, detailedProgressBar: PropTypes.bool.isRequired, showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, showQualityProfile: PropTypes.bool.isRequired, qualityProfile: PropTypes.object.isRequired, showRelativeDates: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js index 2b9f59846..badfa484c 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js @@ -39,6 +39,7 @@ function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) const { detailedProgressBar, showTitle, + showMonitored, showQualityProfile } = posterOptions; @@ -55,6 +56,10 @@ function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) heights.push(19); } + if (showMonitored) { + heights.push(19); + } + if (showQualityProfile) { heights.push(19); } @@ -213,6 +218,7 @@ class ArtistIndexPosters extends Component { const { detailedProgressBar, showTitle, + showMonitored, showQualityProfile } = posterOptions; @@ -231,12 +237,16 @@ class ArtistIndexPosters extends Component { posterHeight={posterHeight} detailedProgressBar={detailedProgressBar} showTitle={showTitle} + showMonitored={showMonitored} showQualityProfile={showQualityProfile} showRelativeDates={showRelativeDates} shortDateFormat={shortDateFormat} timeFormat={timeFormat} style={style} - {...artist} + artistId={artist.id} + languageProfileId={artist.languageProfileId} + qualityProfileId={artist.qualityProfileId} + metadataProfileId={artist.metadataProfileId} /> ); } diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js index 6e0a5aa54..5b946b4c6 100644 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js @@ -30,6 +30,7 @@ class ArtistIndexPosterOptionsModalContent extends Component { detailedProgressBar: props.detailedProgressBar, size: props.size, showTitle: props.showTitle, + showMonitored: props.showMonitored, showQualityProfile: props.showQualityProfile }; } @@ -39,6 +40,7 @@ class ArtistIndexPosterOptionsModalContent extends Component { detailedProgressBar, size, showTitle, + showMonitored, showQualityProfile } = this.props; @@ -56,6 +58,10 @@ class ArtistIndexPosterOptionsModalContent extends Component { state.showTitle = showTitle; } + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + if (showQualityProfile !== prevProps.showQualityProfile) { state.showQualityProfile = showQualityProfile; } @@ -88,6 +94,7 @@ class ArtistIndexPosterOptionsModalContent extends Component { detailedProgressBar, size, showTitle, + showMonitored, showQualityProfile } = this.state; @@ -135,6 +142,18 @@ class ArtistIndexPosterOptionsModalContent extends Component { /> + + Show Monitored + + + + Show Quality Profile @@ -164,6 +183,7 @@ class ArtistIndexPosterOptionsModalContent extends Component { ArtistIndexPosterOptionsModalContent.propTypes = { size: PropTypes.string.isRequired, showTitle: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, showQualityProfile: PropTypes.bool.isRequired, detailedProgressBar: PropTypes.bool.isRequired, onChangePosterOption: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js index acd9b2fa7..d49e3a3b9 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.js @@ -10,34 +10,6 @@ import styles from './ArtistIndexTable.css'; class ArtistIndexTable extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._table = null; - } - - componentDidUpdate(prevProps) { - const { - columns, - filterKey, - filterValue, - sortKey, - sortDirection - } = this.props; - - if (prevProps.columns !== columns || - prevProps.filterKey !== filterKey || - prevProps.filterValue !== filterValue || - prevProps.sortKey !== sortKey || - prevProps.sortDirection !== sortDirection - ) { - this._table.forceUpdateGrid(); - } - } - // // Control @@ -59,10 +31,6 @@ class ArtistIndexTable extends Component { } } - setTableRef = (ref) => { - this._table = ref; - } - rowRenderer = ({ key, rowIndex, style }) => { const { items, @@ -77,7 +45,10 @@ class ArtistIndexTable extends Component { component={ArtistIndexRow} style={style} columns={columns} - {...artist} + artistId={artist.id} + languageProfileId={artist.languageProfileId} + qualityProfileId={artist.qualityProfileId} + metadataProfileId={artist.metadataProfileId} /> ); } @@ -89,6 +60,8 @@ class ArtistIndexTable extends Component { const { items, columns, + filterKey, + filterValue, sortKey, sortDirection, isSmallScreen, @@ -101,7 +74,6 @@ class ArtistIndexTable extends Component { return ( } + columns={columns} + filterKey={filterKey} + filterValue={filterValue} + sortKey={sortKey} + sortDirection={sortDirection} onRender={onRender} onScroll={onScroll} /> diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.css b/frontend/src/Artist/MoveArtist/MoveArtistModal.css new file mode 100644 index 000000000..11f33bef2 --- /dev/null +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.css @@ -0,0 +1,5 @@ +.doNotMoveButton { + composes: button from 'Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Artist/MoveArtist/MoveArtistModal.js b/frontend/src/Artist/MoveArtist/MoveArtistModal.js new file mode 100644 index 000000000..8d5fa2d91 --- /dev/null +++ b/frontend/src/Artist/MoveArtist/MoveArtistModal.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { kinds, sizes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import Modal from 'Components/Modal/Modal'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './MoveArtistModal.css'; + +function MoveArtistModal(props) { + const { + originalPath, + destinationPath, + destinationRootFolder, + isOpen, + onSavePress, + onMoveArtistPress + } = props; + + if ( + isOpen && + !originalPath && + !destinationPath && + !destinationRootFolder + ) { + console.error('orginalPath and destinationPath OR destinationRootFolder must be provied'); + } + + return ( + + + + Move Files + + + + { + destinationRootFolder ? + `Would you like to move the artist folders to ${destinationPath}'?` : + `Would you like to move the artist files from '${originalPath}' to '${destinationPath}'?` + } + + + + + + + + + + ); +} + +MoveArtistModal.propTypes = { + originalPath: PropTypes.string, + destinationPath: PropTypes.string, + destinationRootFolder: PropTypes.string, + isOpen: PropTypes.bool.isRequired, + onSavePress: PropTypes.func.isRequired, + onMoveArtistPress: PropTypes.func.isRequired +}; + +export default MoveArtistModal; diff --git a/frontend/src/Commands/commandNames.js b/frontend/src/Commands/commandNames.js index e432c42e2..f9ff7103a 100644 --- a/frontend/src/Commands/commandNames.js +++ b/frontend/src/Commands/commandNames.js @@ -10,6 +10,7 @@ export const DOWNLOADED_ALBUMS_SCAN = 'DownloadedAlbumsScan'; export const ALBUM_SEARCH = 'AlbumSearch'; export const INTERACTIVE_IMPORT = 'ManualImport'; export const MISSING_ALBUM_SEARCH = 'MissingAlbumSearch'; +export const MOVE_ARTIST = 'MoveArtist'; export const REFRESH_ARTIST = 'RefreshArtist'; export const RENAME_FILES = 'RenameFiles'; export const RENAME_ARTIST = 'RenameArtist'; diff --git a/frontend/src/Components/Form/EnhancedSelectInput.css b/frontend/src/Components/Form/EnhancedSelectInput.css index 7fa7f72cb..dc86311ec 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.css +++ b/frontend/src/Components/Form/EnhancedSelectInput.css @@ -31,12 +31,19 @@ .isDisabled { opacity: 0.7; cursor: not-allowed; + pointer-events: all !important; } .dropdownArrowContainer { margin-left: 12px; } +.dropdownArrowContainerDisabled { + composes: dropdownArrowContainer; + + color: $disabledInputColor; +} + .optionsContainer { width: auto; } diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index f3d923895..d5f9b5d0c 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -289,6 +289,7 @@ class EnhancedSelectInput extends Component { hasWarning && styles.hasWarning, isDisabled && disabledClassName )} + isDisabled={isDisabled} onBlur={this.onBlur} onKeyDown={this.onKeyDown} onPress={this.onPress} @@ -296,11 +297,17 @@ class EnhancedSelectInput extends Component { {selectedOption ? selectedOption.value : null} -
+
diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css index aab9f1b7d..6b8b73af9 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.css @@ -1,3 +1,7 @@ .selectedValue { flex: 1 1 auto; } + +.isDisabled { + color: $disabledInputColor; +} diff --git a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js index 2343fedc2..c40ee93c1 100644 --- a/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js +++ b/frontend/src/Components/Form/EnhancedSelectInputSelectedValue.js @@ -1,15 +1,21 @@ import PropTypes from 'prop-types'; import React from 'react'; +import classNames from 'classnames'; import styles from './EnhancedSelectInputSelectedValue.css'; function EnhancedSelectInputSelectedValue(props) { const { className, - children + children, + isDisabled } = props; return ( -
+
{children}
); @@ -17,11 +23,13 @@ function EnhancedSelectInputSelectedValue(props) { EnhancedSelectInputSelectedValue.propTypes = { className: PropTypes.string.isRequired, - children: PropTypes.node + children: PropTypes.node, + isDisabled: PropTypes.bool.isRequired }; EnhancedSelectInputSelectedValue.defaultProps = { - className: styles.selectedValue + className: styles.selectedValue, + isDisabled: false }; export default EnhancedSelectInputSelectedValue; diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js index e4e221076..1dbe4a793 100644 --- a/frontend/src/Components/Modal/Modal.js +++ b/frontend/src/Components/Modal/Modal.js @@ -109,8 +109,17 @@ class Modal extends Component { } onBackdropEndPress = (event) => { - if (this._isBackdropPressed && this._isBackdropTarget(event)) { - this.props.onModalClose(); + const { + closeOnBackgroundClick, + onModalClose + } = this.props; + + if ( + this._isBackdropPressed && + this._isBackdropTarget(event) && + closeOnBackgroundClick + ) { + onModalClose(); } this._isBackdropPressed = false; @@ -187,13 +196,15 @@ Modal.propTypes = { size: PropTypes.oneOf(sizes.all), children: PropTypes.node, isOpen: PropTypes.bool.isRequired, + closeOnBackgroundClick: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; Modal.defaultProps = { className: styles.modal, backdropClassName: styles.modalBackdrop, - size: sizes.LARGE + size: sizes.LARGE, + closeOnBackgroundClick: true }; export default Modal; diff --git a/frontend/src/Components/Modal/ModalContent.js b/frontend/src/Components/Modal/ModalContent.js index cc165dda2..655046fe4 100644 --- a/frontend/src/Components/Modal/ModalContent.js +++ b/frontend/src/Components/Modal/ModalContent.js @@ -9,6 +9,7 @@ function ModalContent(props) { const { className, children, + showCloseButton, onModalClose, ...otherProps } = props; @@ -18,15 +19,18 @@ function ModalContent(props) { className={className} {...otherProps} > - - - + { + showCloseButton && + + + + } {children}
@@ -36,11 +40,13 @@ function ModalContent(props) { ModalContent.propTypes = { className: PropTypes.string, children: PropTypes.node, + showCloseButton: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; ModalContent.defaultProps = { - className: styles.modalContent + className: styles.modalContent, + showCloseButton: true }; export default ModalContent; diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 50ddc3ae7..b22665a4f 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -482,7 +482,7 @@ class PageSidebar extends Component { key={child.to} title={child.title} to={child.to} - isActive={pathname === child.to} + isActive={pathname.startsWith(child.to)} isParentItem={false} isChildItem={true} statusComponent={child.statusComponent} diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js index df23d3ff7..ca9b49dd8 100644 --- a/frontend/src/Components/Table/VirtualTable.js +++ b/frontend/src/Components/Table/VirtualTable.js @@ -44,7 +44,6 @@ class VirtualTable extends Component { }; this._isInitialized = false; - this._table = null; } componentDidMount() { @@ -58,18 +57,9 @@ class VirtualTable extends Component { return this.props.items[index]; } - setTableRef = (ref) => { - this._table = ref; - } - - forceUpdateGrid = () => { - this._table.recomputeGridSize(); - } - scrollToRow = (rowIndex) => { const scrollTop = (rowIndex + 1) * ROW_HEIGHT + 20; - // this._table.scrollToCell({ columnIndex: 0, rowIndex }); this.props.onScroll({ scrollTop }); } @@ -124,7 +114,6 @@ class VirtualTable extends Component { {header} { + this.props.onFilterExistingFilesChange(value !== filterExistingFilesOptions.ALL); + } + onImportModeChange = ({ value }) => { this.props.onImportModeChange(value); } @@ -155,6 +168,8 @@ class InteractiveImportModalContent extends Component { render() { const { downloadId, + showFilterExistingFiles, + filterExistingFiles, title, folder, isFetching, @@ -205,7 +220,45 @@ class InteractiveImportModalContent extends Component { } { - isPopulated && !!items.length && + isPopulated && showFilterExistingFiles && !isFetching && +
+ + + + +
+ { + filterExistingFiles ? 'Unmapped Files Only' : 'All Files' + } +
+
+ + + + All Files + + + + Unmapped Files Only + + +
+
+ } + + { + isPopulated && !!items.length && !isFetching && !isFetching && @@ -303,6 +356,8 @@ class InteractiveImportModalContent extends Component { InteractiveImportModalContent.propTypes = { downloadId: PropTypes.string, + showFilterExistingFiles: PropTypes.bool.isRequired, + filterExistingFiles: PropTypes.bool.isRequired, importMode: PropTypes.string.isRequired, title: PropTypes.string, folder: PropTypes.string, @@ -314,12 +369,14 @@ InteractiveImportModalContent.propTypes = { sortDirection: PropTypes.string, interactiveImportErrorMessage: PropTypes.string, onSortPress: PropTypes.func.isRequired, + onFilterExistingFilesChange: PropTypes.func.isRequired, onImportModeChange: PropTypes.func.isRequired, onImportSelectedPress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; InteractiveImportModalContent.defaultProps = { + showFilterExistingFiles: false, importMode: 'move' }; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js index fd07437f1..0be91d2d4 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -35,7 +35,8 @@ class InteractiveImportModalContentConnector extends Component { super(props, context); this.state = { - interactiveImportErrorMessage: null + interactiveImportErrorMessage: null, + filterExistingFiles: true }; } @@ -45,7 +46,34 @@ class InteractiveImportModalContentConnector extends Component { folder } = this.props; - this.props.fetchInteractiveImportItems({ downloadId, folder }); + const { + filterExistingFiles + } = this.state; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } + + componentDidUpdate(prevProps, prevState) { + const { + filterExistingFiles + } = this.state; + + if (prevState.filterExistingFiles !== filterExistingFiles) { + const { + downloadId, + folder + } = this.props; + + this.props.fetchInteractiveImportItems({ + downloadId, + folder, + filterExistingFiles + }); + } } componentWillUnmount() { @@ -59,6 +87,10 @@ class InteractiveImportModalContentConnector extends Component { this.props.setInteractiveImportSort({ sortKey, sortDirection }); } + onFilterExistingFilesChange = (filterExistingFiles) => { + this.setState({ filterExistingFiles }); + } + onImportModeChange = (importMode) => { this.props.setInteractiveImportMode({ importMode }); } @@ -122,11 +154,18 @@ class InteractiveImportModalContentConnector extends Component { // Render render() { + const { + interactiveImportErrorMessage, + filterExistingFiles + } = this.state; + return ( @@ -137,6 +176,7 @@ class InteractiveImportModalContentConnector extends Component { InteractiveImportModalContentConnector.propTypes = { downloadId: PropTypes.string, folder: PropTypes.string, + filterExistingFiles: PropTypes.bool.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, fetchInteractiveImportItems: PropTypes.func.isRequired, setInteractiveImportSort: PropTypes.func.isRequired, @@ -146,6 +186,10 @@ InteractiveImportModalContentConnector.propTypes = { onModalClose: PropTypes.func.isRequired }; +InteractiveImportModalContentConnector.defaultProps = { + filterExistingFiles: true +}; + export default connectSection( createMapStateToProps, mapDispatchToProps, diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js index 519ea930d..06e792cf3 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js @@ -15,6 +15,12 @@ import TableBody from 'Components/Table/TableBody'; import SelectTrackRow from './SelectTrackRow'; const columns = [ + { + name: 'mediumNumber', + label: 'Medium', + isSortable: true, + isVisible: true + }, { name: 'trackNumber', label: '#', @@ -127,7 +133,8 @@ class SelectTrackModalContent extends Component { + + {mediumNumber} + + {trackNumber} @@ -53,6 +58,7 @@ class SelectTrackRow extends Component { SelectTrackRow.propTypes = { id: PropTypes.number.isRequired, + mediumNumber: PropTypes.number.isRequired, trackNumber: PropTypes.number.isRequired, title: PropTypes.string.isRequired, isSelected: PropTypes.bool, diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 21d33bea6..ed4090c3f 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -264,7 +264,7 @@ class MediaManagement extends Component { advancedSettings={advancedSettings} isAdvanced={true} > - File chmod mask + File chmod mode - Folder chmod mask + Folder chmod mode { + const newPayload = { + ...payload + }; + + if (payload.moveFiles) { + newPayload.queryParams = { + moveFiles: true + }; + } + + delete newPayload.moveFiles; + + return newPayload; +}); + +export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => { + return { + ...payload, + queryParams: { + deleteFiles: payload.deleteFiles + } + }; +}); + export const toggleArtistMonitored = createThunk(TOGGLE_ARTIST_MONITORED); export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); @@ -58,20 +81,25 @@ export const setArtistValue = createAction(SET_ARTIST_VALUE, (payload) => { }; }); +// +// Helpers + +function getSaveAjaxOptions({ ajaxOptions, payload }) { + if (payload.moveFolder) { + ajaxOptions.url = `${ajaxOptions.url}?moveFolder=true`; + } + + return ajaxOptions; +} + // // Action Handlers export const actionHandlers = handleThunks({ [FETCH_ARTIST]: createFetchHandler(section, '/artist'), - - [SAVE_ARTIST]: createSaveProviderHandler( - section, '/artist'), - - [DELETE_ARTIST]: createRemoveItemHandler( - section, - '/artist' - ), + [SAVE_ARTIST]: createSaveProviderHandler(section, '/artist', { getAjaxOptions: getSaveAjaxOptions }), + [DELETE_ARTIST]: createRemoveItemHandler(section, '/artist'), [TOGGLE_ARTIST_MONITORED]: (getState, payload, dispatch) => { const { @@ -115,7 +143,7 @@ export const actionHandlers = handleThunks({ }); }, - [TOGGLE_ALBUM_MONITORED]: (getState, payload, dispatch) => { + [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) { const { artistId: id, seasonNumber, diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js index 4da674087..b8f2dcc20 100644 --- a/frontend/src/Store/Actions/artistEditorActions.js +++ b/frontend/src/Store/Actions/artistEditorActions.js @@ -112,7 +112,7 @@ export const actionHandlers = handleThunks({ }); promise.done(() => { - // SignaR will take care of removing the serires from the collection + // SignalR will take care of removing the artist from the collection dispatch(set({ section, diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index e3d085b66..c8d978fae 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -28,6 +28,7 @@ export const defaultState = { detailedProgressBar: false, size: 'large', showTitle: false, + showMonitored: true, showQualityProfile: true }, @@ -35,12 +36,14 @@ export const defaultState = { detailedProgressBar: false, size: 'large', showTitle: false, + showMonitored: true, showQualityProfile: true }, overviewOptions: { detailedProgressBar: false, size: 'medium', + showMonitored: true, showNetwork: true, showQualityProfile: true, showPreviousAiring: false, diff --git a/frontend/src/Store/Actions/importArtistActions.js b/frontend/src/Store/Actions/importArtistActions.js index ad4fbf0f3..5d327644d 100644 --- a/frontend/src/Store/Actions/importArtistActions.js +++ b/frontend/src/Store/Actions/importArtistActions.js @@ -2,6 +2,7 @@ import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; import getSectionState from 'Utilities/State/getSectionState'; import updateSectionState from 'Utilities/State/updateSectionState'; import getNewArtist from 'Utilities/Artist/getNewArtist'; @@ -15,14 +16,14 @@ import { fetchRootFolders } from './rootFolderActions'; export const section = 'importArtist'; let concurrentLookups = 0; +let abortCurrentLookup = null; +const queue = []; // // State export const defaultState = { - isFetching: false, - isPopulated: false, - error: null, + isLookingUpArtist: false, isImporting: false, isImported: false, importError: null, @@ -34,9 +35,10 @@ export const defaultState = { export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist'; export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist'; -export const CLEAR_IMPORT_ARTIST = 'importArtist/importArtist'; -export const SET_IMPORT_ARTIST_VALUE = 'importArtist/clearImportArtist'; -export const IMPORT_ARTIST = 'importArtist/setImportArtistValue'; +export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist'; +export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist'; +export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue'; +export const IMPORT_ARTIST = 'importArtist/importArtist'; // // Action Creators @@ -45,10 +47,10 @@ export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST); export const startLookupArtist = createThunk(START_LOOKUP_ARTIST); export const importArtist = createThunk(IMPORT_ARTIST); export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST); +export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST); export const setImportArtistValue = createAction(SET_IMPORT_ARTIST_VALUE, (payload) => { return { - section, ...payload }; @@ -63,7 +65,8 @@ export const actionHandlers = handleThunks({ const { name, path, - term + term, + topOfQueue = false } = payload; const state = getState().importArtist; @@ -84,8 +87,20 @@ export const actionHandlers = handleThunks({ items: [] })); + const itemIndex = queue.indexOf(item.id); + + if (itemIndex >= 0) { + queue.splice(itemIndex, 1); + } + + if (topOfQueue) { + queue.unshift(item.id); + } else { + queue.push(item.id); + } + if (term && term.length > 2) { - dispatch(startLookupArtist()); + dispatch(startLookupArtist({ start: true })); } }, @@ -95,13 +110,27 @@ export const actionHandlers = handleThunks({ } const state = getState().importArtist; - const queued = _.find(state.items, { queued: true }); - if (!queued) { + const { + isLookingUpArtist, + items + } = state; + + const queueId = queue[0]; + + if (payload.start && !isLookingUpArtist) { + dispatch(set({ section, isLookingUpArtist: true })); + } else if (!isLookingUpArtist) { + return; + } else if (!queueId) { + dispatch(set({ section, isLookingUpArtist: false })); return; } concurrentLookups++; + queue.splice(0, 1); + + const queued = items.find((i) => i.id === queueId); dispatch(updateItem({ section, @@ -109,14 +138,16 @@ export const actionHandlers = handleThunks({ isFetching: true })); - const promise = $.ajax({ + const { request, abortRequest } = createAjaxRequest({ url: '/artist/lookup', data: { term: queued.term } }); - promise.done((data) => { + abortCurrentLookup = abortRequest; + + request.done((data) => { dispatch(updateItem({ section, id: queued.id, @@ -125,23 +156,26 @@ export const actionHandlers = handleThunks({ error: null, items: data, queued: false, - selectedArtist: queued.selectedArtist || data[0] + selectedArtist: queued.selectedArtist || data[0], + updateOnly: true })); }); - promise.fail((xhr) => { + request.fail((xhr) => { dispatch(updateItem({ section, id: queued.id, isFetching: false, isPopulated: false, error: xhr, - queued: false + queued: false, + updateOnly: true })); }); - promise.always(() => { + request.always(() => { concurrentLookups--; + dispatch(startLookupArtist()); }); }, @@ -159,7 +193,7 @@ export const actionHandlers = handleThunks({ // Make sure we have a selected artist and // the same artist hasn't been added yet. - if (selectedArtist && !_.some(acc, { tvdbId: selectedArtist.tvdbId })) { + if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) { const newArtist = getNewArtist(_.cloneDeep(selectedArtist), item); newArtist.path = item.path; @@ -216,7 +250,19 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ + [CANCEL_LOOKUP_ARTIST]: function(state) { + return Object.assign({}, state, { isLookingUpArtist: false }); + }, + [CLEAR_IMPORT_ARTIST]: function(state) { + if (abortCurrentLookup) { + abortCurrentLookup(); + + abortCurrentLookup = null; + } + + queue.splice(0, queue.length); + return Object.assign({}, state, defaultState); }, diff --git a/frontend/src/Store/Actions/tagActions.js b/frontend/src/Store/Actions/tagActions.js index 3f5b708fe..29c61cf61 100644 --- a/frontend/src/Store/Actions/tagActions.js +++ b/frontend/src/Store/Actions/tagActions.js @@ -35,24 +35,22 @@ export const addTag = createThunk(ADD_TAG); // Action Handlers export const actionHandlers = handleThunks({ - [FETCH_TAGS]: createFetchHandler('tags', '/tag'), + [FETCH_TAGS]: createFetchHandler(section, '/tag'), - [ADD_TAG]: function(payload) { - return (dispatch, getState) => { - const promise = $.ajax({ - url: '/tag', - method: 'POST', - data: JSON.stringify(payload.tag) - }); + [ADD_TAG]: function(getState, payload, dispatch) { + const promise = $.ajax({ + url: '/tag', + method: 'POST', + data: JSON.stringify(payload.tag) + }); - promise.done((data) => { - const tags = getState().tags.items.slice(); - tags.push(data); + promise.done((data) => { + const tags = getState().tags.items.slice(); + tags.push(data); - dispatch(update({ section: 'tags', data: tags })); - payload.onTagCreated(data); - }); - }; + dispatch(update({ section, data: tags })); + payload.onTagCreated(data); + }); } }); diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js index f46ee3a23..6daa843f4 100644 --- a/frontend/src/Store/thunks.js +++ b/frontend/src/Store/thunks.js @@ -1,12 +1,16 @@ const thunks = {}; -export function createThunk(type) { +function identity(payload) { + return payload; +} + +export function createThunk(type, identityFunction = identity) { return function(payload = {}) { return function(dispatch, getState) { const thunk = thunks[type]; if (thunk) { - return thunk(getState, payload, dispatch); + return thunk(getState, identityFunction(payload), dispatch); } throw Error(`Thunk handler has not been registered for ${type}`); @@ -21,4 +25,3 @@ export function handleThunks(handlers) { thunks[type] = handlers[type]; }); } - diff --git a/frontend/src/Styles/Variables/colors.js b/frontend/src/Styles/Variables/colors.js index 402358e66..ed168cdde 100644 --- a/frontend/src/Styles/Variables/colors.js +++ b/frontend/src/Styles/Variables/colors.js @@ -16,6 +16,7 @@ module.exports = { sonarrBlue: '#00A65B', helpTextColor: '#909293', gray: '#adadad', + disabledInputColor: '#808080', // Theme Colors diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs index f387db154..0b5bc5343 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs @@ -1,7 +1,10 @@ using System.Collections.Generic; +using System.Linq; using Nancy; using NzbDrone.Common.Extensions; +using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; using Lidarr.Http.Extensions; namespace Lidarr.Api.V1.Artist @@ -9,11 +12,13 @@ namespace Lidarr.Api.V1.Artist public class ArtistEditorModule : LidarrV1Module { private readonly IArtistService _artistService; + private readonly IManageCommandQueue _commandQueueManager; - public ArtistEditorModule(IArtistService artistService) + public ArtistEditorModule(IArtistService artistService, IManageCommandQueue commandQueueManager) : base("/artist/editor") { _artistService = artistService; + _commandQueueManager = commandQueueManager; Put["/"] = artist => SaveAll(); Delete["/"] = artist => DeleteArtist(); } @@ -22,6 +27,7 @@ namespace Lidarr.Api.V1.Artist { var resource = Request.Body.FromJson(); var artistToUpdate = _artistService.GetArtists(resource.ArtistIds); + var artistToMove = new List(); foreach (var artist in artistToUpdate) { @@ -53,6 +59,12 @@ namespace Lidarr.Api.V1.Artist if (resource.RootFolderPath.IsNotNullOrWhiteSpace()) { artist.RootFolderPath = resource.RootFolderPath; + artistToMove.Add(new BulkMoveArtist + { + ArtistId = artist.Id, + SourcePath = artist.Path + }); + } if (resource.Tags != null) @@ -75,6 +87,15 @@ namespace Lidarr.Api.V1.Artist } } + if (resource.MoveFiles && artistToMove.Any()) + { + _commandQueueManager.Push(new BulkMoveArtistCommand + { + DestinationRootFolder = resource.RootFolderPath, + Artist = artistToMove + }); + } + return _artistService.UpdateArtists(artistToUpdate) .ToResource() .AsResponse(HttpStatusCode.Accepted); diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs index d50352cd8..30920047b 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs @@ -14,6 +14,7 @@ namespace Lidarr.Api.V1.Artist public string RootFolderPath { get; set; } public List Tags { get; set; } public ApplyTags ApplyTags { get; set; } + public bool MoveFiles { get; set; } } public enum ApplyTags diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs index af3ef571d..596d08e05 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -7,9 +7,11 @@ using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.ArtistStats; using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; using NzbDrone.Core.Music.Events; using NzbDrone.Core.Validation; using NzbDrone.Core.Validation.Paths; @@ -35,12 +37,14 @@ namespace Lidarr.Api.V1.Artist private readonly IAddArtistService _addArtistService; private readonly IArtistStatisticsService _artistStatisticsService; private readonly IMapCoversToLocal _coverMapper; + private readonly IManageCommandQueue _commandQueueManager; public ArtistModule(IBroadcastSignalRMessage signalRBroadcaster, IArtistService artistService, IAddArtistService addArtistService, IArtistStatisticsService artistStatisticsService, IMapCoversToLocal coverMapper, + IManageCommandQueue commandQueueManager, RootFolderValidator rootFolderValidator, ArtistPathValidator artistPathValidator, ArtistExistsValidator artistExistsValidator, @@ -57,6 +61,7 @@ namespace Lidarr.Api.V1.Artist _artistStatisticsService = artistStatisticsService; _coverMapper = coverMapper; + _commandQueueManager = commandQueueManager; GetResourceAll = AllArtists; GetResourceById = GetArtist; @@ -127,7 +132,24 @@ namespace Lidarr.Api.V1.Artist private void UpdateArtist(ArtistResource artistResource) { - var model = artistResource.ToModel(_artistService.GetArtist(artistResource.Id)); + var moveFiles = Request.GetBooleanQueryParameter("moveFiles"); + var artist = _artistService.GetArtist(artistResource.Id); + + if (moveFiles) + { + var sourcePath = artist.Path; + var destinationPath = artistResource.Path; + + _commandQueueManager.Push(new MoveArtistCommand + { + ArtistId = artist.Id, + SourcePath = sourcePath, + DestinationPath = destinationPath, + Trigger = CommandTrigger.Manual + }); + } + + var model = artistResource.ToModel(artist); _artistService.UpdateArtist(model); diff --git a/src/Lidarr.Api.V1/History/HistoryModule.cs b/src/Lidarr.Api.V1/History/HistoryModule.cs index e750349e2..9e72bc9de 100644 --- a/src/Lidarr.Api.V1/History/HistoryModule.cs +++ b/src/Lidarr.Api.V1/History/HistoryModule.cs @@ -55,10 +55,8 @@ namespace Lidarr.Api.V1.History if (model.Artist != null) { - resource.QualityCutoffNotMet = _upgradableSpecification.CutoffNotMet(model.Artist.Profile.Value, - model.Artist.LanguageProfile, - model.Quality, - model.Language); + resource.QualityCutoffNotMet = _upgradableSpecification.QualityCutoffNotMet(model.Artist.Profile.Value, model.Quality); + resource.LanguageCutoffNotMet = _upgradableSpecification.LanguageCutoffNotMet(model.Artist.LanguageProfile, model.Language); } return resource; diff --git a/src/Lidarr.Api.V1/History/HistoryResource.cs b/src/Lidarr.Api.V1/History/HistoryResource.cs index a4a38ed6e..27c168b4b 100644 --- a/src/Lidarr.Api.V1/History/HistoryResource.cs +++ b/src/Lidarr.Api.V1/History/HistoryResource.cs @@ -19,6 +19,7 @@ namespace Lidarr.Api.V1.History public Language Language { get; set; } public QualityModel Quality { get; set; } public bool QualityCutoffNotMet { get; set; } + public bool LanguageCutoffNotMet { get; set; } public DateTime Date { get; set; } public string DownloadId { get; set; } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs index 5d5f9f088..c59b259ef 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs @@ -3,6 +3,8 @@ using System.Linq; using NzbDrone.Core.MediaFiles.TrackImport.Manual; using NzbDrone.Core.Qualities; using Lidarr.Http; +using Lidarr.Http.Extensions; + namespace Lidarr.Api.V1.ManualImport { @@ -22,8 +24,9 @@ namespace Lidarr.Api.V1.ManualImport { var folder = (string)Request.Query.folder; var downloadId = (string)Request.Query.downloadId; + var filterExistingFiles = Request.GetBooleanQueryParameter("filterExistingFiles", true); - return _manualImportService.GetMediaFiles(folder, downloadId).ToResource().Select(AddQualityWeight).ToList(); + return _manualImportService.GetMediaFiles(folder, downloadId, filterExistingFiles).ToResource().Select(AddQualityWeight).ToList(); } private ManualImportResource AddQualityWeight(ManualImportResource item) diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index eabb60f73..fb69f1e2d 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -22,6 +22,8 @@ namespace Lidarr.Api.V1.TrackFiles public MediaInfoResource MediaInfo { get; set; } public bool QualityCutoffNotMet { get; set; } + public bool LanguageCutoffNotMet { get; set; } + } public static class TrackFileResourceMapper @@ -67,11 +69,9 @@ namespace Lidarr.Api.V1.TrackFiles Language = model.Language, Quality = model.Quality, MediaInfo = model.MediaInfo.ToResource(model.SceneName), - - QualityCutoffNotMet = upgradableSpecification.CutoffNotMet(artist.Profile.Value, - artist.LanguageProfile.Value, - model.Quality, - model.Language) + + QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.Profile.Value, model.Quality), + LanguageCutoffNotMet = upgradableSpecification.LanguageCutoffNotMet(artist.LanguageProfile.Value, model.Language) }; } } diff --git a/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs index d7dfa6c5a..05a8b593f 100644 --- a/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/MoveArtistServiceFixture.cs @@ -1,4 +1,6 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; +using System.Linq; using FizzWare.NBuilder; using Moq; using NUnit.Framework; @@ -16,6 +18,7 @@ namespace NzbDrone.Core.Test.MusicTests { private Artist _artist; private MoveArtistCommand _command; + private BulkMoveArtistCommand _bulkCommand; [SetUp] public void Setup() @@ -31,6 +34,19 @@ namespace NzbDrone.Core.Test.MusicTests DestinationPath = @"C:\Test\Music2\Artist".AsOsAgnostic() }; + _bulkCommand = new BulkMoveArtistCommand + { + Artist = new List + { + new BulkMoveArtist + { + ArtistId = 1, + SourcePath = @"C:\Test\Music\Artist".AsOsAgnostic() + } + }, + DestinationRootFolder = @"C:\Test\Music2".AsOsAgnostic() + }; + Mocker.GetMock() .Setup(s => s.GetArtist(It.IsAny())) .Returns(_artist); @@ -48,52 +64,52 @@ namespace NzbDrone.Core.Test.MusicTests { GivenFailedMove(); - Assert.Throws(() => Subject.Execute(_command)); + Subject.Execute(_command); ExceptionVerification.ExpectedErrors(1); } [Test] - public void should_no_update_artist_path_on_error() + public void should_revert_artist_path_on_error() { GivenFailedMove(); - Assert.Throws(() => Subject.Execute(_command)); + Subject.Execute(_command); ExceptionVerification.ExpectedErrors(1); Mocker.GetMock() - .Verify(v => v.UpdateArtist(It.IsAny()), Times.Never()); + .Verify(v => v.UpdateArtist(It.IsAny()), Times.Once()); + } + + [Test] + public void should_use_destination_path() + { + + Subject.Execute(_command); + + Mocker.GetMock() + .Verify(v => v.TransferFolder(_command.SourcePath, _command.DestinationPath, TransferMode.Move, It.IsAny()), Times.Once()); + + Mocker.GetMock() + .Verify(v => v.GetArtistFolder(It.IsAny(), null), Times.Never()); } [Test] public void should_build_new_path_when_root_folder_is_provided() { - _command.DestinationPath = null; - _command.DestinationRootFolder = @"C:\Test\Music3".AsOsAgnostic(); - - var expectedPath = @"C:\Test\Music3\Artist".AsOsAgnostic(); + var artistFolder = "Artist"; + var expectedPath = Path.Combine(_bulkCommand.DestinationRootFolder, artistFolder); + Mocker.GetMock() - .Setup(s => s.GetArtistFolder(It.IsAny(), null)) - .Returns("Artist"); + .Setup(s => s.GetArtistFolder(It.IsAny(), null)) + .Returns(artistFolder); - Subject.Execute(_command); + Subject.Execute(_bulkCommand); - Mocker.GetMock() - .Verify(v => v.UpdateArtist(It.Is(s => s.Path == expectedPath)), Times.Once()); - } - - [Test] - public void should_use_destination_path_if_destination_root_folder_is_blank() - { - Subject.Execute(_command); - - Mocker.GetMock() - .Verify(v => v.UpdateArtist(It.Is(s => s.Path == _command.DestinationPath)), Times.Once()); - - Mocker.GetMock() - .Verify(v => v.GetArtistFolder(It.IsAny(), null), Times.Never()); + Mocker.GetMock() + .Verify(v => v.TransferFolder(_bulkCommand.Artist.First().SourcePath, expectedPath, TransferMode.Move, It.IsAny()), Times.Once()); } } } diff --git a/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs b/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs index cd6d9d3e9..278844294 100644 --- a/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/UpgradableSpecification.cs @@ -9,6 +9,8 @@ namespace NzbDrone.Core.DecisionEngine public interface IUpgradableSpecification { bool IsUpgradable(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality, Language newLanguage); + bool QualityCutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null); + bool LanguageCutoffNotMet(LanguageProfile languageProfile, Language currentLanguage); bool CutoffNotMet(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality = null); bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality); } @@ -68,29 +70,46 @@ namespace NzbDrone.Core.DecisionEngine return true; } - public bool CutoffNotMet(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality = null) + public bool QualityCutoffNotMet(Profile profile, QualityModel currentQuality, QualityModel newQuality = null) { - var languageCompare = new LanguageComparer(languageProfile).Compare(currentLanguage, languageProfile.Cutoff); var qualityCompare = new QualityModelComparer(profile).Compare(currentQuality.Quality.Id, profile.Cutoff); - // If we can upgrade the language (it is not the cutoff) then doesn't matter the quality we can always get same quality with prefered language - if (languageCompare < 0) + if (qualityCompare < 0) { return true; } - if (qualityCompare >= 0) + if (qualityCompare == 0 && newQuality != null && IsRevisionUpgrade(currentQuality, newQuality)) { - if (newQuality != null && IsRevisionUpgrade(currentQuality, newQuality)) - { - return true; - } - - _logger.Debug("Existing item meets cut-off. skipping."); - return false; + return true; } - return true; + return false; + } + + public bool LanguageCutoffNotMet(LanguageProfile languageProfile, Language currentLanguage) + { + var languageCompare = new LanguageComparer(languageProfile).Compare(currentLanguage, languageProfile.Cutoff); + + return languageCompare < 0; + } + + public bool CutoffNotMet(Profile profile, LanguageProfile languageProfile, QualityModel currentQuality, Language currentLanguage, QualityModel newQuality = null) + { + // If we can upgrade the language (it is not the cutoff) then doesn't matter the quality we can always get same quality with prefered language + if (LanguageCutoffNotMet(languageProfile, currentLanguage)) + { + return true; + } + + if (QualityCutoffNotMet(profile, currentQuality, newQuality)) + { + return true; + } + + _logger.Debug("Existing item meets cut-off. skipping."); + + return false; } public bool IsRevisionUpgrade(QualityModel currentQuality, QualityModel newQuality) diff --git a/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs b/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs index af86d061b..26b1077be 100644 --- a/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs +++ b/src/NzbDrone.Core/MediaFiles/Commands/RenameArtistCommand.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.MediaFiles.Commands @@ -11,6 +11,7 @@ namespace NzbDrone.Core.MediaFiles.Commands public RenameArtistCommand() { + ArtistIds = new List(); } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs index acfd2afbd..7859f54f6 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -19,6 +19,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { List GetImportDecisions(List musicFiles, Artist artist); List GetImportDecisions(List musicFiles, Artist artist, ParsedTrackInfo folderInfo); + List GetImportDecisions(List musicFiles, Artist artist, ParsedTrackInfo folderInfo, bool filterExistingFiles); + } public class ImportDecisionMaker : IMakeImportDecision @@ -52,14 +54,19 @@ namespace NzbDrone.Core.MediaFiles.TrackImport public List GetImportDecisions(List musicFiles, Artist artist, ParsedTrackInfo folderInfo) { - var newFiles = _mediaFileService.FilterExistingFiles(musicFiles.ToList(), artist); + return GetImportDecisions(musicFiles, artist, folderInfo, false); + } - _logger.Debug("Analyzing {0}/{1} files.", newFiles.Count, musicFiles.Count()); + public List GetImportDecisions(List musicFiles, Artist artist, ParsedTrackInfo folderInfo, bool filterExistingFiles) + { + var files = filterExistingFiles ? _mediaFileService.FilterExistingFiles(musicFiles.ToList(), artist) : musicFiles.ToList(); + + _logger.Debug("Analyzing {0}/{1} files.", files.Count, musicFiles.Count); var shouldUseFolderName = ShouldUseFolderName(musicFiles, artist, folderInfo); var decisions = new List(); - foreach (var file in newFiles) + foreach (var file in files) { decisions.AddIfNotNull(GetDecision(file, artist, folderInfo, shouldUseFolderName)); } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index e7f53b398..fba198b1d 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -20,7 +20,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual { public interface IManualImportService { - List GetMediaFiles(string path, string downloadId); + List GetMediaFiles(string path, string downloadId, bool filterExistingFiles); } public class ManualImportService : IExecute, IManualImportService @@ -68,7 +68,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual _logger = logger; } - public List GetMediaFiles(string path, string downloadId) + public List GetMediaFiles(string path, string downloadId, bool filterExistingFiles) { if (downloadId.IsNotNullOrWhiteSpace()) { @@ -92,10 +92,10 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual return new List { ProcessFile(path, downloadId) }; } - return ProcessFolder(path, downloadId); + return ProcessFolder(path, downloadId, filterExistingFiles); } - private List ProcessFolder(string folder, string downloadId) + private List ProcessFolder(string folder, string downloadId, bool filterExistingFiles) { var directoryInfo = new DirectoryInfo(folder); var artist = _parsingService.GetArtist(directoryInfo.Name); @@ -115,7 +115,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name); var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); - var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, folderInfo); + var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, folderInfo, filterExistingFiles); return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); } diff --git a/src/NzbDrone.Core/Music/ArtistService.cs b/src/NzbDrone.Core/Music/ArtistService.cs index 9bd021192..f25c08ab2 100644 --- a/src/NzbDrone.Core/Music/ArtistService.cs +++ b/src/NzbDrone.Core/Music/ArtistService.cs @@ -140,8 +140,11 @@ namespace NzbDrone.Core.Music _logger.Trace("Updating: {0}", s.Name); if (!s.RootFolderPath.IsNullOrWhiteSpace()) { - var folderName = new DirectoryInfo(s.Path).Name; - s.Path = Path.Combine(s.RootFolderPath, folderName); + // Build the artist folder name instead of using the existing folder name. + // This may lead to folder name changes, but consistent with adding a new artist. + + s.Path = Path.Combine(s.RootFolderPath, _fileNameBuilder.GetArtistFolder(s)); + _logger.Trace("Changing path for {0} to {1}", s.Name, s.Path); } diff --git a/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs new file mode 100644 index 000000000..52a4cfafd --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/BulkMoveArtistCommand.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class BulkMoveArtistCommand : Command + { + public List Artist { get; set; } + public string DestinationRootFolder { get; set; } + + public override bool SendUpdatesToClient => true; + } + + public class BulkMoveArtist + { + public int ArtistId { get; set; } + public string SourcePath { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs b/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs index 78dc161e0..4ece88c3b 100644 --- a/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs +++ b/src/NzbDrone.Core/Music/Commands/MoveArtistCommand.cs @@ -1,4 +1,4 @@ -using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Commands; namespace NzbDrone.Core.Music.Commands { @@ -7,6 +7,7 @@ namespace NzbDrone.Core.Music.Commands public int ArtistId { get; set; } public string SourcePath { get; set; } public string DestinationPath { get; set; } - public string DestinationRootFolder { get; set; } + + public override bool SendUpdatesToClient => true; } } diff --git a/src/NzbDrone.Core/Music/MoveArtistService.cs b/src/NzbDrone.Core/Music/MoveArtistService.cs index b1f4c52a1..9ae6cc2cf 100644 --- a/src/NzbDrone.Core/Music/MoveArtistService.cs +++ b/src/NzbDrone.Core/Music/MoveArtistService.cs @@ -1,7 +1,6 @@ -using System.IO; +using System.IO; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; @@ -11,7 +10,7 @@ using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.Music { - public class MoveArtistService : IExecute + public class MoveArtistService : IExecute, IExecute { private readonly IArtistService _artistService; private readonly IBuildFileNames _filenameBuilder; @@ -32,38 +31,56 @@ namespace NzbDrone.Core.Music _logger = logger; } - public void Execute(MoveArtistCommand message) + private void MoveSingleArtist(Artist artist, string sourcePath, string destinationPath) { - var artist = _artistService.GetArtist(message.ArtistId); - var source = message.SourcePath; - var destination = message.DestinationPath; + _logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, sourcePath, destinationPath); - if (!message.DestinationRootFolder.IsNullOrWhiteSpace()) - { - _logger.Debug("Buiding destination path using root folder: {0} and the artist name", message.DestinationRootFolder); - destination = Path.Combine(message.DestinationRootFolder, _filenameBuilder.GetArtistFolder(artist)); - } - - _logger.ProgressInfo("Moving {0} from '{1}' to '{2}'", artist.Name, source, destination); - - //TODO: Move to transactional disk operations try { - _diskTransferService.TransferFolder(source, destination, TransferMode.Move); + _diskTransferService.TransferFolder(sourcePath, destinationPath, TransferMode.Move); } catch (IOException ex) { - _logger.Error(ex, "Unable to move artist from '{0}' to '{1}'", source, destination); - throw; + _logger.Error(ex, "Unable to move artist from '{0}' to '{1}'. Try moving files manually", sourcePath, destinationPath); + + RevertPath(artist.Id, sourcePath); } _logger.ProgressInfo("{0} moved successfully to {1}", artist.Name, artist.Path); - //Update the artist path to the new path - artist.Path = destination; - artist = _artistService.UpdateArtist(artist); + _eventAggregator.PublishEvent(new ArtistMovedEvent(artist, sourcePath, destinationPath)); + } - _eventAggregator.PublishEvent(new ArtistMovedEvent(artist, source, destination)); + private void RevertPath(int artistId, string path) + { + var artist = _artistService.GetArtist(artistId); + + artist.Path = path; + _artistService.UpdateArtist(artist); + } + + public void Execute(MoveArtistCommand message) + { + var artist = _artistService.GetArtist(message.ArtistId); + MoveSingleArtist(artist, message.SourcePath, message.DestinationPath); + } + + public void Execute(BulkMoveArtistCommand message) + { + var artistToMove = message.Artist; + var destinationRootFolder = message.DestinationRootFolder; + + _logger.ProgressInfo("Moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); + + foreach (var s in artistToMove) + { + var artist = _artistService.GetArtist(s.ArtistId); + var destinationPath = Path.Combine(destinationRootFolder, _filenameBuilder.GetArtistFolder(artist)); + + MoveSingleArtist(artist, s.SourcePath, destinationPath); + } + + _logger.ProgressInfo("Finished moving {0} artist to '{1}'", artistToMove.Count, destinationRootFolder); } } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index d18316f1a..d71869ef8 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -758,6 +758,7 @@ +