diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css index c478d8b0c..22862a8ca 100644 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.css @@ -58,7 +58,8 @@ composes: button from 'Components/Link/SpinnerButton.css'; } -.hideLanguageProfile { +.hideLanguageProfile, +.hideMetadataProfile { composes: group from 'Components/Form/FormGroup.css'; display: none; diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js index 6f0b0d838..d9028e15b 100644 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContent.js @@ -46,6 +46,10 @@ class AddNewArtistModalContent extends Component { this.props.onInputChange({ name: 'languageProfileId', value: parseInt(value) }); } + onMetadataProfileIdChange = ({ value }) => { + this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) }); + } + onAddArtistPress = () => { this.props.onAddArtistPress(this.state.searchForMissingAlbums); } @@ -63,11 +67,11 @@ class AddNewArtistModalContent extends Component { monitor, qualityProfileId, languageProfileId, + metadataProfileId, albumFolder, - primaryAlbumTypes, - secondaryAlbumTypes, tags, showLanguageProfile, + showMetadataProfile, isSmallScreen, onModalClose, onInputChange @@ -149,16 +153,33 @@ class AddNewArtistModalContent extends Component { /> - - Language Profile - - - + { + showLanguageProfile && + + Language Profile + + + + } + + { + showMetadataProfile && + + Metadata Profile + + + + } Album Folder @@ -225,11 +246,11 @@ AddNewArtistModalContent.propTypes = { monitor: PropTypes.object.isRequired, qualityProfileId: PropTypes.object, languageProfileId: PropTypes.object, + metadataProfileId: PropTypes.object, albumFolder: PropTypes.object.isRequired, - primaryAlbumTypes: PropTypes.object.isRequired, - secondaryAlbumTypes: PropTypes.object.isRequired, tags: PropTypes.object.isRequired, showLanguageProfile: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired, diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js index b971432c3..a0163bb07 100644 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtistModalContentConnector.js @@ -11,8 +11,9 @@ function createMapStateToProps() { return createSelector( (state) => state.addArtist, (state) => state.settings.languageProfiles, + (state) => state.settings.metadataProfiles, createDimensionsSelector(), - (addArtistState, languageProfiles, dimensions) => { + (addArtistState, languageProfiles, metadataProfiles, dimensions) => { const { isAdding, addError, @@ -28,7 +29,8 @@ function createMapStateToProps() { return { isAdding, addError, - showLanguageProfile: languageProfiles.length > 1, + showLanguageProfile: languageProfiles.items.length > 1, + showMetadataProfile: metadataProfiles.items.length > 1, isSmallScreen: dimensions.isSmallScreen, validationErrors, validationWarnings, @@ -59,9 +61,8 @@ class AddNewArtistModalContentConnector extends Component { monitor, qualityProfileId, languageProfileId, + metadataProfileId, albumFolder, - primaryAlbumTypes, - secondaryAlbumTypes, tags } = this.props; @@ -71,9 +72,8 @@ class AddNewArtistModalContentConnector extends Component { monitor: monitor.value, qualityProfileId: qualityProfileId.value, languageProfileId: languageProfileId.value, + metadataProfileId: metadataProfileId.value, albumFolder: albumFolder.value, - primaryAlbumTypes: primaryAlbumTypes.value, - secondaryAlbumTypes: secondaryAlbumTypes.value, tags: tags.value, searchForMissingAlbums }); @@ -99,9 +99,8 @@ AddNewArtistModalContentConnector.propTypes = { monitor: PropTypes.object.isRequired, qualityProfileId: PropTypes.object, languageProfileId: PropTypes.object, + metadataProfileId: PropTypes.object, albumFolder: PropTypes.object.isRequired, - primaryAlbumTypes: PropTypes.object.isRequired, - secondaryAlbumTypes: PropTypes.object.isRequired, tags: PropTypes.object.isRequired, onModalClose: PropTypes.func.isRequired, setAddArtistDefault: PropTypes.func.isRequired, diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js index 3c4bea3b6..fc8ad079c 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtist.js @@ -87,7 +87,8 @@ class ImportArtist extends Component { rootFoldersPopulated, rootFoldersError, unmappedFolders, - showLanguageProfile + showLanguageProfile, + showMetadataProfile } = this.props; const { @@ -130,6 +131,7 @@ class ImportArtist extends Component { selectedState={selectedState} contentBody={contentBody} showLanguageProfile={showLanguageProfile} + showMetadataProfile={showMetadataProfile} scrollTop={this.state.scrollTop} onSelectAllChange={this.onSelectAllChange} onSelectedChange={this.onSelectedChange} @@ -144,6 +146,7 @@ class ImportArtist extends Component { @@ -162,6 +165,7 @@ ImportArtist.propTypes = { unmappedFolders: PropTypes.arrayOf(PropTypes.object), items: PropTypes.arrayOf(PropTypes.object), showLanguageProfile: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onImportPress: PropTypes.func.isRequired }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js index 504cb54dd..df7a9173a 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistConnector.js @@ -16,7 +16,8 @@ function createMapStateToProps() { (state) => state.addArtist, (state) => state.importArtist, (state) => state.settings.languageProfiles, - (match, rootFolders, addArtist, importArtistState, languageProfiles) => { + (state) => state.settings.metadataProfiles, + (match, rootFolders, addArtist, importArtistState, languageProfiles, metadataProfiles) => { const { isFetching: rootFoldersFetching, isPopulated: rootFoldersPopulated, @@ -31,7 +32,8 @@ function createMapStateToProps() { rootFoldersFetching, rootFoldersPopulated, rootFoldersError, - showLanguageProfile: languageProfiles.items.length > 1 + showLanguageProfile: languageProfiles.items.length > 1, + showMetadataProfile: metadataProfiles.items.length > 1 }; if (items.length) { diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js index 76fe864f8..0b42e25b5 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooter.js @@ -23,18 +23,16 @@ class ImportArtistFooter extends Component { defaultMonitor, defaultQualityProfileId, defaultLanguageProfileId, - defaultAlbumFolder, - defaultPrimaryAlbumTypes, - defaultSecondaryAlbumTypes + defaultMetadataProfileId, + defaultAlbumFolder } = props; this.state = { monitor: defaultMonitor, qualityProfileId: defaultQualityProfileId, languageProfileId: defaultLanguageProfileId, - albumFolder: defaultAlbumFolder, - primaryAlbumTypes: defaultPrimaryAlbumTypes, - secondaryAlbumTypes: defaultSecondaryAlbumTypes + metadataProfileId: defaultMetadataProfileId, + albumFolder: defaultAlbumFolder }; } @@ -43,24 +41,21 @@ class ImportArtistFooter extends Component { defaultMonitor, defaultQualityProfileId, defaultLanguageProfileId, + defaultMetadataProfileId, defaultAlbumFolder, - defaultPrimaryAlbumTypes, - defaultSecondaryAlbumTypes, isMonitorMixed, isQualityProfileIdMixed, isLanguageProfileIdMixed, - isAlbumFolderMixed, - isPrimaryAlbumTypesMixed, - isSecondaryAlbumTypesMixed + isMetadataProfileIdMixed, + isAlbumFolderMixed } = this.props; const { monitor, qualityProfileId, languageProfileId, - albumFolder, - primaryAlbumTypes, - secondaryAlbumTypes + metadataProfileId, + albumFolder } = this.state; const newState = {}; @@ -83,24 +78,18 @@ class ImportArtistFooter extends Component { newState.languageProfileId = defaultLanguageProfileId; } + if (isMetadataProfileIdMixed && metadataProfileId !== MIXED) { + newState.metadataProfileId = MIXED; + } else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) { + newState.metadataProfileId = defaultMetadataProfileId; + } + if (isAlbumFolderMixed && albumFolder != null) { newState.albumFolder = null; } else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) { newState.albumFolder = defaultAlbumFolder; } - if (isPrimaryAlbumTypesMixed && primaryAlbumTypes != null) { - newState.primaryAlbumTypes = null; - } else if (!isPrimaryAlbumTypesMixed && primaryAlbumTypes !== defaultPrimaryAlbumTypes) { - newState.primaryAlbumTypes = defaultPrimaryAlbumTypes; - } - - if (isSecondaryAlbumTypesMixed && secondaryAlbumTypes != null) { - newState.secondaryAlbumTypes = null; - } else if (!isSecondaryAlbumTypesMixed && secondaryAlbumTypes !== defaultSecondaryAlbumTypes) { - newState.secondaryAlbumTypes = defaultSecondaryAlbumTypes; - } - if (!_.isEmpty(newState)) { this.setState(newState); } @@ -125,7 +114,9 @@ class ImportArtistFooter extends Component { isMonitorMixed, isQualityProfileIdMixed, isLanguageProfileIdMixed, + isMetadataProfileIdMixed, showLanguageProfile, + showMetadataProfile, onImportPress } = this.props; @@ -133,9 +124,8 @@ class ImportArtistFooter extends Component { monitor, qualityProfileId, languageProfileId, - albumFolder, - primaryAlbumTypes, - secondaryAlbumTypes + metadataProfileId, + albumFolder } = this.state; return ( @@ -189,6 +179,25 @@ class ImportArtistFooter extends Component { } + { + showMetadataProfile && + +
+
+ Metadata Profile +
+ + +
+ } +
Album Folder @@ -244,16 +253,15 @@ ImportArtistFooter.propTypes = { defaultMonitor: PropTypes.string.isRequired, defaultQualityProfileId: PropTypes.number, defaultLanguageProfileId: PropTypes.number, + defaultMetadataProfileId: PropTypes.number, defaultAlbumFolder: PropTypes.bool.isRequired, - defaultPrimaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired, - defaultSecondaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired, isMonitorMixed: PropTypes.bool.isRequired, isQualityProfileIdMixed: PropTypes.bool.isRequired, isLanguageProfileIdMixed: PropTypes.bool.isRequired, + isMetadataProfileIdMixed: PropTypes.bool.isRequired, isAlbumFolderMixed: PropTypes.bool.isRequired, - isPrimaryAlbumTypesMixed: PropTypes.bool.isRequired, - isSecondaryAlbumTypesMixed: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onImportPress: PropTypes.func.isRequired }; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js index 938efd916..bf3c901ec 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistFooterConnector.js @@ -19,9 +19,8 @@ function createMapStateToProps() { monitor: defaultMonitor, qualityProfileId: defaultQualityProfileId, languageProfileId: defaultLanguageProfileId, - albumFolder: defaultAlbumFolder, - primaryAlbumTypes: defaultPrimaryAlbumTypes, - secondaryAlbumTypes: defaultSecondaryAlbumTypes + metadataProfileId: defaultMetadataProfileId, + albumFolder: defaultAlbumFolder } = addArtist.defaults; const items = importArtist.items; @@ -33,9 +32,8 @@ function createMapStateToProps() { const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor'); const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId'); const isLanguageProfileIdMixed = isMixed(items, selectedIds, defaultLanguageProfileId, 'languageProfileId'); + const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId'); const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder'); - const isPrimaryAlbumTypesMixed = isMixed(items, selectedIds, defaultPrimaryAlbumTypes, 'primaryAlbumTypes'); - const isSecondaryAlbumTypesMixed = isMixed(items, selectedIds, defaultSecondaryAlbumTypes, 'secondaryAlbumTypes'); return { selectedCount: selectedIds.length, @@ -44,15 +42,13 @@ function createMapStateToProps() { defaultMonitor, defaultQualityProfileId, defaultLanguageProfileId, + defaultMetadataProfileId, defaultAlbumFolder, - defaultPrimaryAlbumTypes, - defaultSecondaryAlbumTypes, isMonitorMixed, isQualityProfileIdMixed, isLanguageProfileIdMixed, - isAlbumFolderMixed, - isPrimaryAlbumTypesMixed, - isSecondaryAlbumTypesMixed + isMetadataProfileIdMixed, + isAlbumFolderMixed }; } ); diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css index a42c0c696..f43704565 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.css @@ -12,7 +12,8 @@ } .qualityProfile, -.languageProfile { +.languageProfile, +.metadataProfile { composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; flex: 0 1 250px; diff --git a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js index edb07beb4..f0ec10566 100644 --- a/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js +++ b/frontend/src/AddArtist/ImportArtist/Import/ImportArtistHeader.js @@ -13,6 +13,7 @@ import styles from './ImportArtistHeader.css'; function ImportArtistHeader(props) { const { showLanguageProfile, + showMetadataProfile, allSelected, allUnselected, onSelectAllChange @@ -69,6 +70,16 @@ function ImportArtistHeader(props) { } + { + showMetadataProfile && + + Metadata Profile + + } + + + + + { @@ -125,6 +123,7 @@ class ImportArtistTable extends Component { items, selectedState, showLanguageProfile, + showMetadataProfile, onSelectedChange } = this.props; @@ -136,6 +135,7 @@ class ImportArtistTable extends Component { style={style} rootFolderId={rootFolderId} showLanguageProfile={showLanguageProfile} + showMetadataProfile={showMetadataProfile} isSelected={selectedState[item.id]} onSelectedChange={onSelectedChange} id={item.id} @@ -154,6 +154,7 @@ class ImportArtistTable extends Component { isSmallScreen, contentBody, showLanguageProfile, + showMetadataProfile, scrollTop, onSelectAllChange, onScroll @@ -176,6 +177,7 @@ class ImportArtistTable extends Component { header={ { - primaryAlbumTypes.slice(0).map((albumType) => { + albumTypes.slice(0).map((albumType) => { return ( ); @@ -546,7 +573,6 @@ ArtistDetails.propTypes = { links: PropTypes.arrayOf(PropTypes.object).isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, albums: PropTypes.arrayOf(PropTypes.object).isRequired, - primaryAlbumTypes: PropTypes.arrayOf(PropTypes.string).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, isRefreshing: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Edit/EditArtistModalContent.js b/frontend/src/Artist/Edit/EditArtistModalContent.js index ae45f6332..390170a49 100644 --- a/frontend/src/Artist/Edit/EditArtistModalContent.js +++ b/frontend/src/Artist/Edit/EditArtistModalContent.js @@ -24,6 +24,7 @@ class EditArtistModalContent extends Component { item, isSaving, showLanguageProfile, + showMetadataProfile, onInputChange, onSavePress, onModalClose, @@ -36,6 +37,7 @@ class EditArtistModalContent extends Component { albumFolder, qualityProfileId, languageProfileId, + metadataProfileId, path, tags } = item; @@ -99,6 +101,21 @@ class EditArtistModalContent extends Component { } + { + showMetadataProfile && + + Metadata Profile + + + + } + Path @@ -155,6 +172,7 @@ EditArtistModalContent.propTypes = { item: PropTypes.object.isRequired, isSaving: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, diff --git a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js index 8676584a3..2cadea5d5 100644 --- a/frontend/src/Artist/Edit/EditArtistModalContentConnector.js +++ b/frontend/src/Artist/Edit/EditArtistModalContentConnector.js @@ -12,8 +12,9 @@ function createMapStateToProps() { return createSelector( (state) => state.artist, (state) => state.settings.languageProfiles, + (state) => state.settings.metadataProfiles, createArtistSelector(), - (artistState, languageProfiles, artist) => { + (artistState, languageProfiles, metadataProfiles, artist) => { const { isSaving, saveError, @@ -25,6 +26,7 @@ function createMapStateToProps() { 'albumFolder', 'qualityProfileId', 'languageProfileId', + 'metadataProfileId', 'path', 'tags' ]); @@ -38,6 +40,7 @@ function createMapStateToProps() { pendingChanges, item: settings.settings, showLanguageProfile: languageProfiles.items.length > 1, + showMetadataProfile: metadataProfiles.items.length > 1, ...settings }; } diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js index 17b0fa91a..1cd9c78f3 100644 --- a/frontend/src/Artist/Editor/ArtistEditor.js +++ b/frontend/src/Artist/Editor/ArtistEditor.js @@ -19,7 +19,7 @@ import ArtistEditorRowConnector from './ArtistEditorRowConnector'; import ArtistEditorFooter from './ArtistEditorFooter'; import OrganizeArtistModal from './Organize/OrganizeArtistModal'; -function getColumns(showLanguageProfile) { +function getColumns(showLanguageProfile, showMetadataProfile) { return [ { name: 'status', @@ -43,6 +43,12 @@ function getColumns(showLanguageProfile) { isSortable: true, isVisible: showLanguageProfile }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + isSortable: true, + isVisible: showMetadataProfile + }, { name: 'albumFolder', label: 'Album Folder', @@ -78,7 +84,7 @@ class ArtistEditor extends Component { lastToggled: null, selectedState: {}, isOrganizingArtistModalOpen: false, - columns: getColumns(props.showLanguageProfile) + columns: getColumns(props.showLanguageProfile, props.showMetadataProfile) }; } @@ -155,6 +161,7 @@ class ArtistEditor extends Component { deleteError, isOrganizingArtist, showLanguageProfile, + showMetadataProfile, onSortPress, onFilterSelect } = this.props; @@ -285,6 +292,7 @@ class ArtistEditor extends Component { deleteError={deleteError} isOrganizingArtist={isOrganizingArtist} showLanguageProfile={showLanguageProfile} + showMetadataProfile={showMetadataProfile} onSaveSelected={this.onSaveSelected} onOrganizeArtistPress={this.onOrganizeArtistPress} /> @@ -314,6 +322,7 @@ ArtistEditor.propTypes = { deleteError: PropTypes.object, isOrganizingArtist: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired, + showMetadataProfile: PropTypes.bool.isRequired, onSortPress: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired, onSaveSelected: PropTypes.func.isRequired diff --git a/frontend/src/Artist/Editor/ArtistEditorConnector.js b/frontend/src/Artist/Editor/ArtistEditorConnector.js index e99e09861..55b859f58 100644 --- a/frontend/src/Artist/Editor/ArtistEditorConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorConnector.js @@ -12,12 +12,14 @@ import ArtistEditor from './ArtistEditor'; function createMapStateToProps() { return createSelector( (state) => state.settings.languageProfiles, + (state) => state.settings.metadataProfiles, createClientSideCollectionSelector(), createCommandSelector(commandNames.RENAME_ARTIST), - (languageProfiles, artist, isOrganizingArtist) => { + (languageProfiles, metadataProfiles, artist, isOrganizingArtist) => { return { isOrganizingArtist, showLanguageProfile: languageProfiles.items.length > 1, + showMetadataProfile: metadataProfiles.items.length > 1, ...artist }; } diff --git a/frontend/src/Artist/Editor/ArtistEditorFooter.js b/frontend/src/Artist/Editor/ArtistEditorFooter.js index 382c5d092..13efff18f 100644 --- a/frontend/src/Artist/Editor/ArtistEditorFooter.js +++ b/frontend/src/Artist/Editor/ArtistEditorFooter.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { kinds } from 'Helpers/Props'; import SelectInput from 'Components/Form/SelectInput'; import LanguageProfileSelectInputConnector from 'Components/Form/LanguageProfileSelectInputConnector'; +import MetadataProfileSelectInputConnector from 'Components/Form/MetadataProfileSelectInputConnector'; import QualityProfileSelectInputConnector from 'Components/Form/QualityProfileSelectInputConnector'; import RootFolderSelectInputConnector from 'Components/Form/RootFolderSelectInputConnector'; import SpinnerButton from 'Components/Link/SpinnerButton'; @@ -26,6 +27,7 @@ class ArtistEditorFooter extends Component { monitored: NO_CHANGE, qualityProfileId: NO_CHANGE, languageProfileId: NO_CHANGE, + metadataProfileId: NO_CHANGE, albumFolder: NO_CHANGE, rootFolderPath: NO_CHANGE, savingTags: false, @@ -45,6 +47,7 @@ class ArtistEditorFooter extends Component { monitored: NO_CHANGE, qualityProfileId: NO_CHANGE, languageProfileId: NO_CHANGE, + metadataProfileId: NO_CHANGE, albumFolder: NO_CHANGE, rootFolderPath: NO_CHANGE, savingTags: false @@ -113,6 +116,7 @@ class ArtistEditorFooter extends Component { isDeleting, isOrganizingArtist, showLanguageProfile, + showMetadataProfile, onOrganizeArtistPress } = this.props; @@ -120,6 +124,7 @@ class ArtistEditorFooter extends Component { monitored, qualityProfileId, languageProfileId, + metadataProfileId, albumFolder, rootFolderPath, savingTags, @@ -189,6 +194,24 @@ class ArtistEditorFooter extends Component {
} + { + showMetadataProfile && +
+ + + +
+ } +
} + { + _.find(columns, { name: 'metadataProfileId' }).isVisible && + + {metadataProfile.name} + + } + { + (languageProfile, metadataProfile, qualityProfile) => { return { languageProfile, + metadataProfile, qualityProfile }; } diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js index 216c3fbbe..2e60088fe 100644 --- a/frontend/src/Artist/Index/ArtistIndexItemConnector.js +++ b/frontend/src/Artist/Index/ArtistIndexItemConnector.js @@ -6,6 +6,7 @@ import { createSelector } from 'reselect'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector'; import createLanguageProfileSelector from 'Store/Selectors/createLanguageProfileSelector'; +import createMetadataProfileSelector from 'Store/Selectors/createMetadataProfileSelector'; import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; @@ -15,8 +16,9 @@ function createMapStateToProps() { (state, { albums }) => albums, createQualityProfileSelector(), createLanguageProfileSelector(), + createMetadataProfileSelector(), createCommandsSelector(), - (artistId, albums, qualityProfile, languageProfile, commands) => { + (artistId, albums, qualityProfile, languageProfile, metadataProfile, commands) => { const isRefreshingArtist = _.some(commands, (command) => { return command.name === commandNames.REFRESH_ARTIST && command.body.artistId === artistId; @@ -27,6 +29,7 @@ function createMapStateToProps() { return { qualityProfile, languageProfile, + metadataProfile, latestAlbum, isRefreshingArtist }; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js index c4c70e0d2..1f3ee21bc 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js @@ -51,6 +51,15 @@ function ArtistIndexSortMenu(props) { Language Profile + + Metadata Profile + + + {metadataProfile.name} + + ); + } + if (name === 'nextAiring') { return ( state.settings.metadataProfiles, + (state, { includeNoChange }) => includeNoChange, + (state, { includeMixed }) => includeMixed, + (metadataProfiles, includeNoChange, includeMixed) => { + const values = _.map(metadataProfiles.items.sort(sortByName), (metadataProfile) => { + return { + key: metadataProfile.id, + value: metadataProfile.name + }; + }); + + if (includeNoChange) { + values.unshift({ + key: 'noChange', + value: 'No Change', + disabled: true + }); + } + + if (includeMixed) { + values.unshift({ + key: 'mixed', + value: '(Mixed)', + disabled: true + }); + } + + return { + values + }; + } + ); +} + +class MetadataProfileSelectInputConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + name, + value, + values + } = this.props; + + if (!value || !_.some(values, (option) => parseInt(option.key) === value)) { + const firstValue = _.find(values, (option) => !isNaN(parseInt(option.key))); + + if (firstValue) { + this.onChange({ name, value: firstValue.key }); + } + } + } + + // + // Listeners + + onChange = ({ name, value }) => { + this.props.onChange({ name, value: parseInt(value) }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadataProfileSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + values: PropTypes.arrayOf(PropTypes.object).isRequired, + includeNoChange: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired +}; + +MetadataProfileSelectInputConnector.defaultProps = { + includeNoChange: false +}; + +export default connect(createMapStateToProps)(MetadataProfileSelectInputConnector); diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 416b194b9..05430ec07 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -7,7 +7,7 @@ import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchTags } from 'Store/Actions/tagActions'; -import { fetchQualityProfiles, fetchLanguageProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; +import { fetchQualityProfiles, fetchLanguageProfiles, fetchMetadataProfiles, fetchUISettings } from 'Store/Actions/settingsActions'; import { fetchStatus } from 'Store/Actions/systemActions'; import ErrorPage from './ErrorPage'; import LoadingPage from './LoadingPage'; @@ -75,6 +75,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchLanguageProfiles() { dispatch(fetchLanguageProfiles()); }, + dispatchFetchMetadataProfiles() { + dispatch(fetchMetadataProfiles()); + }, dispatchFetchUISettings() { dispatch(fetchUISettings()); }, @@ -109,6 +112,7 @@ class PageConnector extends Component { this.props.dispatchFetchTags(); this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchLanguageProfiles(); + this.props.dispatchFetchMetadataProfiles(); this.props.dispatchFetchUISettings(); this.props.dispatchFetchStatus(); } @@ -132,6 +136,7 @@ class PageConnector extends Component { dispatchFetchTags, dispatchFetchQualityProfiles, dispatchFetchLanguageProfiles, + dispatchFetchMetadataProfiles, dispatchFetchUISettings, dispatchFetchStatus, ...otherProps @@ -169,6 +174,7 @@ PageConnector.propTypes = { dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchLanguageProfiles: PropTypes.func.isRequired, + dispatchFetchMetadataProfiles: PropTypes.func.isRequired, dispatchFetchUISettings: PropTypes.func.isRequired, dispatchFetchStatus: PropTypes.func.isRequired, onSidebarVisibleChange: PropTypes.func.isRequired diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 124f5b88b..492ce5417 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -7,6 +7,7 @@ export const PASSWORD = 'password'; export const PATH = 'path'; export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect'; +export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; @@ -24,6 +25,7 @@ export const all = [ PATH, QUALITY_PROFILE_SELECT, LANGUAGE_PROFILE_SELECT, + METADATA_PROFILE_SELECT, ROOT_FOLDER_SELECT, SELECT, SERIES_TYPE_SELECT, diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js new file mode 100644 index 000000000..24037604d --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditMetadataProfileModalContentConnector from './EditMetadataProfileModalContentConnector'; + +function EditMetadataProfileModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditMetadataProfileModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditMetadataProfileModal; diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js new file mode 100644 index 000000000..987630c98 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalConnector.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditMetadataProfileModal from './EditMetadataProfileModal'; + +function mapStateToProps() { + return {}; +} + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditMetadataProfileModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'metadataProfiles' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditMetadataProfileModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EditMetadataProfileModalConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css new file mode 100644 index 000000000..74dd1c8b7 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.css @@ -0,0 +1,3 @@ +.deleteButtonContainer { + margin-right: auto; +} diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js new file mode 100644 index 000000000..9ead1a809 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContent.js @@ -0,0 +1,145 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, kinds } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +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 Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import PrimaryTypeItems from './PrimaryTypeItems'; +import SecondaryTypeItems from './SecondaryTypeItems'; +import styles from './EditMetadataProfileModalContent.css'; + +function EditMetadataProfileModalContent(props) { + const { + isFetching, + error, + isSaving, + saveError, + primaryAlbumTypes, + secondaryAlbumTypes, + item, + isInUse, + onInputChange, + onSavePress, + onModalClose, + onDeleteMetadataProfilePress, + ...otherProps + } = props; + + const { + id, + name, + primaryAlbumTypes: itemPrimaryAlbumTypes, + secondaryAlbumTypes: itemSecondaryAlbumTypes + } = item; + + return ( + + + {id ? 'Edit Metadata Profile' : 'Add Metadata Profile'} + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to add a new metadata profile, please try again.
+ } + + { + !isFetching && !error && +
+ + Name + + + + + + + + + + } +
+ + { + id && +
+ +
+ } + + + + + Save + +
+
+ ); +} + +EditMetadataProfileModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + item: PropTypes.object.isRequired, + isInUse: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteMetadataProfilePress: PropTypes.func +}; + +export default EditMetadataProfileModalContent; diff --git a/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js new file mode 100644 index 000000000..0de0cbfc4 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/EditMetadataProfileModalContentConnector.js @@ -0,0 +1,180 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { createSelector } from 'reselect'; +import createProfileInUseSelector from 'Store/Selectors/createProfileInUseSelector'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import { fetchMetadataProfileSchema, setMetadataProfileValue, saveMetadataProfile } from 'Store/Actions/settingsActions'; +import connectSection from 'Store/connectSection'; +import EditMetadataProfileModalContent from './EditMetadataProfileModalContent'; + +function createPrimaryAlbumTypesSelector() { + return createSelector( + createProviderSettingsSelector(), + (metadataProfile) => { + const primaryAlbumTypes = metadataProfile.item.primaryAlbumTypes; + if (!primaryAlbumTypes || !primaryAlbumTypes.value) { + return []; + } + + return _.reduceRight(primaryAlbumTypes.value, (result, { allowed, albumType }) => { + if (allowed) { + result.push({ + key: albumType.id, + value: albumType.name + }); + } + + return result; + }, []); + } + ); +} + +function createSecondaryAlbumTypesSelector() { + return createSelector( + createProviderSettingsSelector(), + (metadataProfile) => { + const secondaryAlbumTypes = metadataProfile.item.secondaryAlbumTypes; + if (!secondaryAlbumTypes || !secondaryAlbumTypes.value) { + return []; + } + + return _.reduceRight(secondaryAlbumTypes.value, (result, { allowed, albumType }) => { + if (allowed) { + result.push({ + key: albumType.id, + value: albumType.name + }); + } + + return result; + }, []); + } + ); +} + +function createMapStateToProps() { + return createSelector( + createProviderSettingsSelector(), + createPrimaryAlbumTypesSelector(), + createSecondaryAlbumTypesSelector(), + createProfileInUseSelector('metadataProfileId'), + (metadataProfile, primaryAlbumTypes, secondaryAlbumTypes, isInUse) => { + return { + primaryAlbumTypes, + secondaryAlbumTypes, + ...metadataProfile, + isInUse + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadataProfileSchema, + setMetadataProfileValue, + saveMetadataProfile +}; + +class EditMetadataProfileModalContentConnector extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + dragIndex: null, + dropIndex: null + }; + } + + componentDidMount() { + if (!this.props.id) { + this.props.fetchMetadataProfileSchema(); + } + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setMetadataProfileValue({ name, value }); + } + + onSavePress = () => { + this.props.saveMetadataProfile({ id: this.props.id }); + } + + onMetadataPrimaryTypeItemAllowedChange = (id, allowed) => { + const metadataProfile = _.cloneDeep(this.props.item); + + const item = _.find(metadataProfile.primaryAlbumTypes.value, (i) => i.albumType.id === id); + item.allowed = allowed; + + this.props.setMetadataProfileValue({ + name: 'primaryAlbumTypes', + value: metadataProfile.primaryAlbumTypes.value + }); + } + + onMetadataSecondaryTypeItemAllowedChange = (id, allowed) => { + const metadataProfile = _.cloneDeep(this.props.item); + + const item = _.find(metadataProfile.secondaryAlbumTypes.value, (i) => i.albumType.id === id); + item.allowed = allowed; + + this.props.setMetadataProfileValue({ + name: 'secondaryAlbumTypes', + value: metadataProfile.secondaryAlbumTypes.value + }); + } + + // + // Render + + render() { + if (_.isEmpty(this.props.item.primaryAlbumTypes) && !this.props.isFetching) { + return null; + } + + return ( + + ); + } +} + +EditMetadataProfileModalContentConnector.propTypes = { + id: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + item: PropTypes.object.isRequired, + setMetadataProfileValue: PropTypes.func.isRequired, + fetchMetadataProfileSchema: PropTypes.func.isRequired, + saveMetadataProfile: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connectSection( + createMapStateToProps, + mapDispatchToProps, + undefined, + undefined, + { section: 'metadataProfiles' } +)(EditMetadataProfileModalContentConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css new file mode 100644 index 000000000..c6c9f365d --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.css @@ -0,0 +1,19 @@ +.metadataProfile { + composes: card from 'Components/Card.css'; + + width: 300px; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.albumTypes { + display: flex; + flex-wrap: wrap; + margin-top: 5px; +} diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js new file mode 100644 index 000000000..e0b1e2bcc --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfile.js @@ -0,0 +1,142 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { kinds } from 'Helpers/Props'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector'; +import styles from './MetadataProfile.css'; + +class MetadataProfile extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditMetadataProfileModalOpen: false, + isDeleteMetadataProfileModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataProfilePress = () => { + this.setState({ isEditMetadataProfileModalOpen: true }); + } + + onEditMetadataProfileModalClose = () => { + this.setState({ isEditMetadataProfileModalOpen: false }); + } + + onDeleteMetadataProfilePress = () => { + this.setState({ + isEditMetadataProfileModalOpen: false, + isDeleteMetadataProfileModalOpen: true + }); + } + + onDeleteMetadataProfileModalClose = () => { + this.setState({ isDeleteMetadataProfileModalOpen: false }); + } + + onConfirmDeleteMetadataProfile = () => { + this.props.onConfirmDeleteMetadataProfile(this.props.id); + } + + // + // Render + + render() { + const { + id, + name, + primaryAlbumTypes, + secondaryAlbumTypes, + isDeleting + } = this.props; + + return ( + +
+ {name} +
+ +
+ { + primaryAlbumTypes.map((item) => { + if (!item.allowed) { + return null; + } + + return ( + + ); + }) + } +
+ +
+ { + secondaryAlbumTypes.map((item) => { + if (!item.allowed) { + return null; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); + } +} + +MetadataProfile.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + primaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + secondaryAlbumTypes: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteMetadataProfile: PropTypes.func.isRequired +}; + +export default MetadataProfile; diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css new file mode 100644 index 000000000..9817ab257 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.css @@ -0,0 +1,21 @@ +.metadataProfiles { + display: flex; + flex-wrap: wrap; +} + +.addMetadataProfile { + composes: metadataProfile from './MetadataProfile.css'; + + background-color: $cardAlternateBackgroundColor; + color: $gray; + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; +} diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js new file mode 100644 index 000000000..5ff9c9190 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfiles.js @@ -0,0 +1,102 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import sortByName from 'Utilities/Array/sortByName'; +import { icons } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Card from 'Components/Card'; +import Icon from 'Components/Icon'; +import PageSectionContent from 'Components/Page/PageSectionContent'; +import MetadataProfile from './MetadataProfile'; +import EditMetadataProfileModalConnector from './EditMetadataProfileModalConnector'; +import styles from './MetadataProfiles.css'; + +class MetadataProfiles extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMetadataProfileModalOpen: false + }; + } + + // + // Listeners + + onEditMetadataProfilePress = () => { + this.setState({ isMetadataProfileModalOpen: true }); + } + + onModalClose = () => { + this.setState({ isMetadataProfileModalOpen: false }); + } + + // + // Render + + render() { + const { + items, + isDeleting, + onConfirmDeleteMetadataProfile, + ...otherProps + } = this.props; + + return ( +
+ +
+ { + items.sort(sortByName).map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + +
+
+ ); + } +} + +MetadataProfiles.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteMetadataProfile: PropTypes.func.isRequired +}; + +export default MetadataProfiles; diff --git a/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js new file mode 100644 index 000000000..636548306 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/MetadataProfilesConnector.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchMetadataProfiles, deleteMetadataProfile } from 'Store/Actions/settingsActions'; +import MetadataProfiles from './MetadataProfiles'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + (state) => state.settings.metadataProfiles, + (advancedSettings, metadataProfiles) => { + return { + advancedSettings, + ...metadataProfiles + }; + } + ); +} + +const mapDispatchToProps = { + fetchMetadataProfiles, + deleteMetadataProfile +}; + +class MetadataProfilesConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchMetadataProfiles(); + } + + // + // Listeners + + onConfirmDeleteMetadataProfile = (id) => { + this.props.deleteMetadataProfile({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +MetadataProfilesConnector.propTypes = { + fetchMetadataProfiles: PropTypes.func.isRequired, + deleteMetadataProfile: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(MetadataProfilesConnector); diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js new file mode 100644 index 000000000..3d5d42a9b --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItem.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TypeItem.css'; + +class PrimaryTypeItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + albumTypeId, + onMetadataPrimaryTypeItemAllowedChange + } = this.props; + + onMetadataPrimaryTypeItemAllowedChange(albumTypeId, value); + } + + // + // Render + + render() { + const { + name, + allowed + } = this.props; + + return ( +
+ +
+ ); + } +} + +PrimaryTypeItem.propTypes = { + albumTypeId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + onMetadataPrimaryTypeItemAllowedChange: PropTypes.func +}; + +export default PrimaryTypeItem; diff --git a/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js new file mode 100644 index 000000000..487adbbd6 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/PrimaryTypeItems.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import PrimaryTypeItem from './PrimaryTypeItem'; +import styles from './TypeItems.css'; + +class PrimaryTypeItems extends Component { + + // + // Render + + render() { + const { + metadataProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + return ( + + Primary Types +
+ + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + metadataProfileItems.map(({ allowed, albumType }, index) => { + return ( + + ); + }).reverse() + } +
+
+
+ ); + } +} + +PrimaryTypeItems.propTypes = { + metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + formLabel: PropTypes.string +}; + +PrimaryTypeItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default PrimaryTypeItems; diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js new file mode 100644 index 000000000..79995a920 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItem.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import CheckInput from 'Components/Form/CheckInput'; +import styles from './TypeItem.css'; + +class SecondaryTypeItem extends Component { + + // + // Listeners + + onAllowedChange = ({ value }) => { + const { + albumTypeId, + onMetadataSecondaryTypeItemAllowedChange + } = this.props; + + onMetadataSecondaryTypeItemAllowedChange(albumTypeId, value); + } + + // + // Render + + render() { + const { + name, + allowed + } = this.props; + + return ( +
+ +
+ ); + } +} + +SecondaryTypeItem.propTypes = { + albumTypeId: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + allowed: PropTypes.bool.isRequired, + sortIndex: PropTypes.number.isRequired, + onMetadataSecondaryTypeItemAllowedChange: PropTypes.func +}; + +export default SecondaryTypeItem; diff --git a/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js new file mode 100644 index 000000000..0fb407f68 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/SecondaryTypeItems.js @@ -0,0 +1,87 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import SecondaryTypeItem from './SecondaryTypeItem'; +import styles from './TypeItems.css'; + +class SecondaryTypeItems extends Component { + + // + // Render + + render() { + const { + metadataProfileItems, + errors, + warnings, + ...otherProps + } = this.props; + + return ( + + Secondary Types +
+ + { + errors.map((error, index) => { + return ( + + ); + }) + } + + { + warnings.map((warning, index) => { + return ( + + ); + }) + } + +
+ { + metadataProfileItems.map(({ allowed, albumType }, index) => { + return ( + + ); + }).reverse() + } +
+
+
+ ); + } +} + +SecondaryTypeItems.propTypes = { + metadataProfileItems: PropTypes.arrayOf(PropTypes.object).isRequired, + errors: PropTypes.arrayOf(PropTypes.object), + warnings: PropTypes.arrayOf(PropTypes.object), + formLabel: PropTypes.string +}; + +SecondaryTypeItems.defaultProps = { + errors: [], + warnings: [] +}; + +export default SecondaryTypeItems; diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItem.css b/frontend/src/Settings/Profiles/Metadata/TypeItem.css new file mode 100644 index 000000000..908f3bde6 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/TypeItem.css @@ -0,0 +1,25 @@ +.metadataProfileItem { + display: flex; + align-items: stretch; + width: 100%; +} + +.checkContainer { + position: relative; + margin-right: 4px; + margin-bottom: 7px; + margin-left: 8px; +} + +.albumTypeName { + display: flex; + flex-grow: 1; + margin-bottom: 0; + margin-left: 2px; + font-weight: normal; + line-height: 36px; +} + +.isDragging { + opacity: 0.25; +} diff --git a/frontend/src/Settings/Profiles/Metadata/TypeItems.css b/frontend/src/Settings/Profiles/Metadata/TypeItems.css new file mode 100644 index 000000000..3bce22799 --- /dev/null +++ b/frontend/src/Settings/Profiles/Metadata/TypeItems.css @@ -0,0 +1,6 @@ +.albumTypes { + margin-top: 10px; + /* TODO: This should consider the number of types in the list */ + min-height: 200px; + user-select: none; +} diff --git a/frontend/src/Settings/Profiles/Profiles.js b/frontend/src/Settings/Profiles/Profiles.js index ed288ca9b..1534d7a15 100644 --- a/frontend/src/Settings/Profiles/Profiles.js +++ b/frontend/src/Settings/Profiles/Profiles.js @@ -6,6 +6,7 @@ import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import QualityProfilesConnector from './Quality/QualityProfilesConnector'; import LanguageProfilesConnector from './Language/LanguageProfilesConnector'; +import MetadataProfilesConnector from './Metadata/MetadataProfilesConnector'; import DelayProfilesConnector from './Delay/DelayProfilesConnector'; class Profiles extends Component { @@ -23,6 +24,7 @@ class Profiles extends Component { + diff --git a/frontend/src/Settings/Settings.js b/frontend/src/Settings/Settings.js index c39830f63..56df72695 100644 --- a/frontend/src/Settings/Settings.js +++ b/frontend/src/Settings/Settings.js @@ -32,7 +32,7 @@ function Settings() {
- Quality, Language and Delay profiles + Quality, Language, Metadata, and Delay profiles
state.settings.languageProfiles), + [types.FETCH_METADATA_PROFILES]: createFetchHandler('metadataProfiles', '/metadataprofile'), + [types.FETCH_METADATA_PROFILE_SCHEMA]: createFetchSchemaHandler('metadataProfiles', '/metadataprofile/schema'), + + [types.SAVE_METADATA_PROFILE]: createSaveProviderHandler( + 'metadataProfiles', + '/metadataprofile', + (state) => state.settings.metadataProfiles), + + [types.DELETE_METADATA_PROFILE]: createRemoveItemHandler( + 'metadataProfiles', + '/metadataprofile', + (state) => state.settings.metadataProfiles), + [types.FETCH_DELAY_PROFILES]: createFetchHandler('delayProfiles', '/delayprofile'), [types.SAVE_DELAY_PROFILE]: createSaveProviderHandler( diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 64acc7926..44ec54d54 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -57,6 +57,18 @@ export const setLanguageProfileValue = createAction(types.SET_LANGUAGE_PROFILE_V }; }); +export const fetchMetadataProfiles = settingsActionHandlers[types.FETCH_METADATA_PROFILES]; +export const fetchMetadataProfileSchema = settingsActionHandlers[types.FETCH_METADATA_PROFILE_SCHEMA]; +export const saveMetadataProfile = settingsActionHandlers[types.SAVE_METADATA_PROFILE]; +export const deleteMetadataProfile = settingsActionHandlers[types.DELETE_METADATA_PROFILE]; + +export const setMetadataProfileValue = createAction(types.SET_METADATA_PROFILE_VALUE, (payload) => { + return { + section: 'metadataProfiles', + ...payload + }; +}); + export const fetchDelayProfiles = settingsActionHandlers[types.FETCH_DELAY_PROFILES]; export const saveDelayProfile = settingsActionHandlers[types.SAVE_DELAY_PROFILE]; export const deleteDelayProfile = settingsActionHandlers[types.DELETE_DELAY_PROFILE]; diff --git a/frontend/src/Store/Reducers/addArtistReducers.js b/frontend/src/Store/Reducers/addArtistReducers.js index 951d362ef..374c1a8d1 100644 --- a/frontend/src/Store/Reducers/addArtistReducers.js +++ b/frontend/src/Store/Reducers/addArtistReducers.js @@ -22,8 +22,7 @@ export const defaultState = { monitor: 'allEpisodes', qualityProfileId: 0, languageProfileId: 0, - primaryAlbumTypes: ['Album', 'EP'], - secondaryAlbumTypes: ['Studio'], + metadataProfileId: 0, albumFolder: true, tags: [] } diff --git a/frontend/src/Store/Reducers/artistIndexReducers.js b/frontend/src/Store/Reducers/artistIndexReducers.js index cdc45e7e8..eb057622b 100644 --- a/frontend/src/Store/Reducers/artistIndexReducers.js +++ b/frontend/src/Store/Reducers/artistIndexReducers.js @@ -76,6 +76,12 @@ export const defaultState = { isSortable: true, isVisible: false }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + isSortable: true, + isVisible: false + }, { name: 'nextAiring', label: 'Next Airing', diff --git a/frontend/src/Store/Reducers/settingsReducers.js b/frontend/src/Store/Reducers/settingsReducers.js index 2e92ef6c1..170897eb0 100644 --- a/frontend/src/Store/Reducers/settingsReducers.js +++ b/frontend/src/Store/Reducers/settingsReducers.js @@ -83,6 +83,22 @@ export const defaultState = { pendingChanges: {} }, + metadataProfiles: { + isFetching: false, + isPopulated: false, + error: null, + isDeleting: false, + deleteError: null, + isFetchingSchema: false, + schemaPopulated: false, + schemaError: null, + schema: {}, + isSaving: false, + saveError: null, + items: [], + pendingChanges: {} + }, + delayProfiles: { isFetching: false, isPopulated: false, @@ -243,6 +259,7 @@ const propertyNames = [ const providerPropertyNames = [ 'qualityProfiles', 'languageProfiles', + 'metadataProfiles', 'delayProfiles', 'indexers', 'restrictions', @@ -270,6 +287,7 @@ const settingsReducers = handleActions({ [types.SET_NAMING_SETTINGS_VALUE]: createSetSettingValueReducer('naming'), [types.SET_QUALITY_PROFILE_VALUE]: createSetSettingValueReducer('qualityProfiles'), [types.SET_LANGUAGE_PROFILE_VALUE]: createSetSettingValueReducer('languageProfiles'), + [types.SET_METADATA_PROFILE_VALUE]: createSetSettingValueReducer('metadataProfiles'), [types.SET_DELAY_PROFILE_VALUE]: createSetSettingValueReducer('delayProfiles'), [types.SET_QUALITY_DEFINITION_VALUE]: function(state, { payload }) { diff --git a/frontend/src/Store/Selectors/createMetadataProfileSelector.js b/frontend/src/Store/Selectors/createMetadataProfileSelector.js new file mode 100644 index 000000000..6cbb8685a --- /dev/null +++ b/frontend/src/Store/Selectors/createMetadataProfileSelector.js @@ -0,0 +1,14 @@ +import _ from 'lodash'; +import { createSelector } from 'reselect'; + +function createMetadataProfileSelector() { + return createSelector( + (state, { metadataProfileId }) => metadataProfileId, + (state) => state.settings.metadataProfiles.items, + (metadataProfileId, metadataProfiles) => { + return _.find(metadataProfiles, { id: metadataProfileId }); + } + ); +} + +export default createMetadataProfileSelector; diff --git a/frontend/src/Utilities/Series/getNewSeries.js b/frontend/src/Utilities/Series/getNewSeries.js index 558d2f775..a19028309 100644 --- a/frontend/src/Utilities/Series/getNewSeries.js +++ b/frontend/src/Utilities/Series/getNewSeries.js @@ -6,10 +6,9 @@ function getNewSeries(artist, payload) { monitor, qualityProfileId, languageProfileId, + metadataProfileId, artistType, albumFolder, - primaryAlbumTypes, - secondaryAlbumTypes, tags, searchForMissingAlbums = false } = payload; @@ -25,11 +24,10 @@ function getNewSeries(artist, payload) { artist.monitored = true; artist.qualityProfileId = qualityProfileId; artist.languageProfileId = languageProfileId; + artist.metadataProfileId = metadataProfileId; artist.rootFolderPath = rootFolderPath; artist.artistType = artistType; artist.albumFolder = albumFolder; - artist.primaryAlbumTypes = primaryAlbumTypes; - artist.secondaryAlbumTypes = secondaryAlbumTypes; artist.tags = tags; return artist; diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs index 56c7a373b..f387db154 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorModule.cs @@ -35,6 +35,16 @@ namespace Lidarr.Api.V1.Artist artist.ProfileId = resource.QualityProfileId.Value; } + if (resource.LanguageProfileId.HasValue) + { + artist.LanguageProfileId = resource.LanguageProfileId.Value; + } + + if (resource.MetadataProfileId.HasValue) + { + artist.MetadataProfileId = resource.MetadataProfileId.Value; + } + if (resource.AlbumFolder.HasValue) { artist.AlbumFolder = resource.AlbumFolder.Value; diff --git a/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs index b8724213d..edff77346 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistEditorResource.cs @@ -9,6 +9,7 @@ namespace Lidarr.Api.V1.Artist public bool? Monitored { get; set; } public int? QualityProfileId { get; set; } public int? LanguageProfileId { get; set; } + public int? MetadataProfileId { get; set; } //public SeriesTypes? SeriesType { get; set; } public bool? AlbumFolder { get; set; } public string RootFolderPath { get; set; } diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index b4c4e69bc..3d25e83a4 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -51,6 +51,7 @@ namespace Lidarr.Api.V1.Artist public string Path { get; set; } public int QualityProfileId { get; set; } public int LanguageProfileId { get; set; } + public int MetadataProfileId { get; set; } //Editing Only public bool AlbumFolder { get; set; } @@ -89,9 +90,6 @@ namespace Lidarr.Api.V1.Artist ArtistType = model.ArtistType, Disambiguation = model.Disambiguation, - PrimaryAlbumTypes = model.PrimaryAlbumTypes, - SecondaryAlbumTypes = model.SecondaryAlbumTypes, - Images = model.Images, Albums = model.Albums.ToResource(), @@ -100,6 +98,7 @@ namespace Lidarr.Api.V1.Artist Path = model.Path, QualityProfileId = model.ProfileId, LanguageProfileId = model.LanguageProfileId, + MetadataProfileId = model.MetadataProfileId, Links = model.Links, AlbumFolder = model.AlbumFolder, @@ -146,9 +145,8 @@ namespace Lidarr.Api.V1.Artist Path = resource.Path, ProfileId = resource.QualityProfileId, LanguageProfileId = resource.LanguageProfileId, + MetadataProfileId = resource.MetadataProfileId, Links = resource.Links, - PrimaryAlbumTypes = resource.PrimaryAlbumTypes, - SecondaryAlbumTypes = resource.SecondaryAlbumTypes, AlbumFolder = resource.AlbumFolder, Monitored = resource.Monitored, diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index 349e4b142..e69985c87 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -94,6 +94,10 @@ + + + + diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs new file mode 100644 index 000000000..dffc500f2 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileModule.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileModule : LidarrRestModule + { + private readonly IMetadataProfileService _profileService; + + public MetadataProfileModule(IMetadataProfileService profileService) + { + _profileService = profileService; + SharedValidator.RuleFor(c => c.Name).NotEmpty(); + SharedValidator.RuleFor(c => c.PrimaryAlbumTypes).MustHaveAllowedPrimaryType(); + SharedValidator.RuleFor(c => c.SecondaryAlbumTypes).MustHaveAllowedSecondaryType(); + + GetResourceAll = GetAll; + GetResourceById = GetById; + UpdateResource = Update; + CreateResource = Create; + DeleteResource = DeleteProfile; + } + + private int Create(MetadataProfileResource resource) + { + var model = resource.ToModel(); + model = _profileService.Add(model); + return model.Id; + } + + private void DeleteProfile(int id) + { + _profileService.Delete(id); + } + + private void Update(MetadataProfileResource resource) + { + var model = resource.ToModel(); + + _profileService.Update(model); + } + + private MetadataProfileResource GetById(int id) + { + return _profileService.Get(id).ToResource(); + } + + private List GetAll() + { + var profiles = _profileService.All().ToResource(); + + return profiles; + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs new file mode 100644 index 000000000..a7fca5bea --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileResource.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileResource : RestResource + { + public string Name { get; set; } + public List PrimaryAlbumTypes { get; set; } + public List SecondaryAlbumTypes { get; set; } + } + + public class ProfilePrimaryAlbumTypeItemResource : RestResource + { + public NzbDrone.Core.Music.PrimaryAlbumType AlbumType { get; set; } + public bool Allowed { get; set; } + } + + public class ProfileSecondaryAlbumTypeItemResource : RestResource + { + public NzbDrone.Core.Music.SecondaryAlbumType AlbumType { get; set; } + public bool Allowed { get; set; } + } + + public static class MetadataProfileResourceMapper + { + public static MetadataProfileResource ToResource(this MetadataProfile model) + { + if (model == null) return null; + + return new MetadataProfileResource + { + Id = model.Id, + Name = model.Name, + PrimaryAlbumTypes = model.PrimaryAlbumTypes.ConvertAll(ToResource), + SecondaryAlbumTypes = model.SecondaryAlbumTypes.ConvertAll(ToResource) + }; + } + + public static ProfilePrimaryAlbumTypeItemResource ToResource(this ProfilePrimaryAlbumTypeItem model) + { + if (model == null) return null; + + return new ProfilePrimaryAlbumTypeItemResource + { + AlbumType = model.PrimaryAlbumType, + Allowed = model.Allowed + }; + } + + public static ProfileSecondaryAlbumTypeItemResource ToResource(this ProfileSecondaryAlbumTypeItem model) + { + if (model == null) + { + return null; + } + + return new ProfileSecondaryAlbumTypeItemResource + { + AlbumType = model.SecondaryAlbumType, + Allowed = model.Allowed + }; + } + + public static MetadataProfile ToModel(this MetadataProfileResource resource) + { + if (resource == null) + { + return null; + } + + return new MetadataProfile + { + Id = resource.Id, + Name = resource.Name, + PrimaryAlbumTypes = resource.PrimaryAlbumTypes.ConvertAll(ToModel), + SecondaryAlbumTypes = resource.SecondaryAlbumTypes.ConvertAll(ToModel) + }; + } + + public static ProfilePrimaryAlbumTypeItem ToModel(this ProfilePrimaryAlbumTypeItemResource resource) + { + if (resource == null) return null; + + return new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = (NzbDrone.Core.Music.PrimaryAlbumType)resource.AlbumType.Id, + Allowed = resource.Allowed + }; + } + + public static ProfileSecondaryAlbumTypeItem ToModel(this ProfileSecondaryAlbumTypeItemResource resource) + { + if (resource == null) return null; + + return new ProfileSecondaryAlbumTypeItem + { + SecondaryAlbumType = (NzbDrone.Core.Music.SecondaryAlbumType)resource.AlbumType.Id, + Allowed = resource.Allowed + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs new file mode 100644 index 000000000..05196fdc5 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataProfileSchemaModule.cs @@ -0,0 +1,41 @@ +using System.Linq; +using NzbDrone.Core.Profiles.Metadata; +using Lidarr.Http; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public class MetadataProfileSchemaModule : LidarrRestModule + { + + public MetadataProfileSchemaModule() + : base("/metadataprofile/schema") + { + GetResourceSingle = GetAll; + } + + private MetadataProfileResource GetAll() + { + var orderedPrimTypes = NzbDrone.Core.Music.PrimaryAlbumType.All + .OrderByDescending(l => l.Id) + .ToList(); + + var orderedSecTypes = NzbDrone.Core.Music.SecondaryAlbumType.All + .OrderByDescending(l => l.Id) + .ToList(); + + var primTypes = orderedPrimTypes.Select(v => new ProfilePrimaryAlbumTypeItem {PrimaryAlbumType = v, Allowed = false}) + .ToList(); + + var secTypes = orderedSecTypes.Select(v => new ProfileSecondaryAlbumTypeItem { SecondaryAlbumType = v, Allowed = false }) + .ToList(); + + var profile = new MetadataProfile + { + PrimaryAlbumTypes = primTypes, + SecondaryAlbumTypes = secTypes + }; + + return profile.ToResource(); + } + } +} diff --git a/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs new file mode 100644 index 000000000..830a4aeb8 --- /dev/null +++ b/src/Lidarr.Api.V1/Profiles/Metadata/MetadataValidator.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using FluentValidation.Validators; + +namespace Lidarr.Api.V1.Profiles.Metadata +{ + public static class MetadataValidation + { + public static IRuleBuilderOptions> MustHaveAllowedPrimaryType(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + + return ruleBuilder.SetValidator(new PrimaryTypeValidator()); + } + + public static IRuleBuilderOptions> MustHaveAllowedSecondaryType(this IRuleBuilder> ruleBuilder) + { + ruleBuilder.SetValidator(new NotEmptyValidator(null)); + + return ruleBuilder.SetValidator(new SecondaryTypeValidator()); + } + } + + + public class PrimaryTypeValidator : PropertyValidator + { + public PrimaryTypeValidator() + : base("Must have at least one allowed primary type") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } + + public class SecondaryTypeValidator : PropertyValidator + { + public SecondaryTypeValidator() + : base("Must have at least one allowed secondary type") + { + } + + protected override bool IsValid(PropertyValidatorContext context) + { + var list = context.PropertyValue as IList; + + if (list == null) + { + return false; + } + + if (!list.Any(c => c.Allowed)) + { + return false; + } + + return true; + } + } +} diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs index 3a61b9ca8..a766cba47 100644 --- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs +++ b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs @@ -26,7 +26,7 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook [TestCase("66c662b6-6e2f-4930-8610-912e24c63ed1", "AC/DC")] public void should_be_able_to_get_artist_detail(string mbId, string name) { - var details = Subject.GetArtistInfo(mbId, new List { "Album" }, new List { "Studio" }); + var details = Subject.GetArtistInfo(mbId, 0); ValidateArtist(details.Item1); ValidateAlbums(details.Item2); @@ -37,13 +37,13 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook [Test] public void getting_details_of_invalid_artist() { - Assert.Throws(() => Subject.GetArtistInfo("aaaaaa-aaa-aaaa-aaaa", new List { "Album" }, new List { "Studio" })); + Assert.Throws(() => Subject.GetArtistInfo("aaaaaa-aaa-aaaa-aaaa", 0)); } [Test] public void should_not_have_period_at_start_of_name_slug() { - var details = Subject.GetArtistInfo("b6db95cd-88d9-492f-bbf6-a34e0e89b2e5", new List { "Album" }, new List { "Studio" }); + var details = Subject.GetArtistInfo("b6db95cd-88d9-492f-bbf6-a34e0e89b2e5", 0); details.Item1.NameSlug.Should().Be("dothack"); } diff --git a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs index 98edf35ca..38f064433 100644 --- a/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/AddArtistFixture.cs @@ -33,7 +33,7 @@ namespace NzbDrone.Core.Test.MusicTests private void GivenValidArtist(string lidarrId) { Mocker.GetMock() - .Setup(s => s.GetArtistInfo(lidarrId, It.IsAny>(), It.IsAny>())) + .Setup(s => s.GetArtistInfo(lidarrId, It.IsAny())) .Returns(new Tuple>(_fakeArtist, new List())); } @@ -113,7 +113,7 @@ namespace NzbDrone.Core.Test.MusicTests }; Mocker.GetMock() - .Setup(s => s.GetArtistInfo(newArtist.ForeignArtistId, newArtist.PrimaryAlbumTypes, newArtist.SecondaryAlbumTypes)) + .Setup(s => s.GetArtistInfo(newArtist.ForeignArtistId, newArtist.MetadataProfileId)) .Throws(new ArtistNotFoundException(newArtist.ForeignArtistId)); Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs index 77b1febe5..83775dea4 100644 --- a/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/RefreshArtistServiceFixture.cs @@ -38,14 +38,14 @@ namespace NzbDrone.Core.Test.MusicTests .Returns(_artist); Mocker.GetMock() - .Setup(s => s.GetArtistInfo(It.IsAny(), It.IsAny>(), It.IsAny>())) + .Setup(s => s.GetArtistInfo(It.IsAny(), It.IsAny())) .Callback(() => { throw new ArtistNotFoundException(_artist.ForeignArtistId); }); } private void GivenNewArtistInfo(Artist artist) { Mocker.GetMock() - .Setup(s => s.GetArtistInfo(_artist.ForeignArtistId, _artist.PrimaryAlbumTypes, _artist.SecondaryAlbumTypes)) + .Setup(s => s.GetArtistInfo(_artist.ForeignArtistId, _artist.MetadataProfileId)) .Returns(new Tuple>(artist, new List())); } diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 8ed6036fc..2a9160394 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -280,6 +280,8 @@ + + diff --git a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs new file mode 100644 index 000000000..3294725c1 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileRepositoryFixture.cs @@ -0,0 +1,42 @@ +using FluentAssertions; +using System.Linq; +using NUnit.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.Test.Profiles.Metadata +{ + [TestFixture] + public class MetadataProfileRepositoryFixture : DbTest + { + [Test] + public void should_be_able_to_read_and_write() + { + var profile = new MetadataProfile + { + PrimaryAlbumTypes = PrimaryAlbumType.All.OrderByDescending(l => l.Name).Select(l => new ProfilePrimaryAlbumTypeItem + { + PrimaryAlbumType = l, + Allowed = l == PrimaryAlbumType.Album + }).ToList(), + + SecondaryAlbumTypes = SecondaryAlbumType.All.OrderByDescending(l => l.Name).Select(l => new ProfileSecondaryAlbumTypeItem + { + SecondaryAlbumType = l, + Allowed = l == SecondaryAlbumType.Studio + }).ToList(), + + Name = "TestProfile" + }; + + Subject.Insert(profile); + + + StoredModel.Name.Should().Be(profile.Name); + + StoredModel.PrimaryAlbumTypes.Should().Equal(profile.PrimaryAlbumTypes, (a, b) => a.PrimaryAlbumType == b.PrimaryAlbumType && a.Allowed == b.Allowed); + StoredModel.SecondaryAlbumTypes.Should().Equal(profile.SecondaryAlbumTypes, (a, b) => a.SecondaryAlbumType == b.SecondaryAlbumType && a.Allowed == b.Allowed); + } + } +} diff --git a/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs new file mode 100644 index 000000000..51899ea66 --- /dev/null +++ b/src/NzbDrone.Core.Test/Profiles/Metadata/MetadataProfileServiceFixture.cs @@ -0,0 +1,75 @@ +using System.Linq; +using FizzWare.NBuilder; +using Moq; +using NUnit.Framework; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Music; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.Test.Profiles.Metadata +{ + [TestFixture] + + public class MetadataProfileServiceFixture : CoreTest + { + [Test] + public void init_should_add_default_profiles() + { + Subject.Handle(new ApplicationStartedEvent()); + + Mocker.GetMock() + .Verify(v => v.Insert(It.IsAny()), Times.Once()); + } + + [Test] + //This confirms that new profiles are added only if no other profiles exists. + //We don't want to keep adding them back if a user deleted them on purpose. + public void Init_should_skip_if_any_profiles_already_exist() + { + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(Builder.CreateListOfSize(2).Build().ToList()); + + Subject.Handle(new ApplicationStartedEvent()); + + Mocker.GetMock() + .Verify(v => v.Insert(It.IsAny()), Times.Never()); + } + + + [Test] + public void should_not_be_able_to_delete_profile_if_assigned_to_artist() + { + var artistList = Builder.CreateListOfSize(3) + .Random(1) + .With(c => c.MetadataProfileId = 2) + .Build().ToList(); + + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + + Assert.Throws(() => Subject.Delete(2)); + + Mocker.GetMock().Verify(c => c.Delete(It.IsAny()), Times.Never()); + + } + + + [Test] + public void should_delete_profile_if_not_assigned_to_series() + { + var artistList = Builder.CreateListOfSize(3) + .All() + .With(c => c.MetadataProfileId = 2) + .Build().ToList(); + + + Mocker.GetMock().Setup(c => c.GetAllArtists()).Returns(artistList); + + Subject.Delete(1); + + Mocker.GetMock().Verify(c => c.Delete(1), Times.Once()); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/PrimaryAlbumTypeIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/PrimaryAlbumTypeIntConverter.cs new file mode 100644 index 000000000..c4c8cdaab --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/PrimaryAlbumTypeIntConverter.cs @@ -0,0 +1,63 @@ +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Core.Music; +using System; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class PrimaryAlbumTypeIntConverter : JsonConverter, IConverter + { + public object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return PrimaryAlbumType.Album; + } + + var val = Convert.ToInt32(context.DbValue); + + return (PrimaryAlbumType) val; + } + + public object FromDB(ColumnMap map, object dbValue) + { + return FromDB(new ConverterContext {ColumnMap = map, DbValue = dbValue}); + } + + public object ToDB(object clrValue) + { + if (clrValue == DBNull.Value) + { + return 0; + } + + if (clrValue as PrimaryAlbumType == null) + { + throw new InvalidOperationException("Attempted to save a albumtype that isn't really a albumtype"); + } + + var language = (PrimaryAlbumType) clrValue; + return (int) language; + } + + public Type DbType => typeof(int); + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(PrimaryAlbumType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, + JsonSerializer serializer) + { + var item = reader.Value; + return (PrimaryAlbumType) Convert.ToInt32(item); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(ToDB(value)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs b/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs new file mode 100644 index 000000000..33bc41c76 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/SecondaryAlbumTypeIntConverter.cs @@ -0,0 +1,63 @@ +using System; +using Marr.Data.Converters; +using Marr.Data.Mapping; +using Newtonsoft.Json; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class SecondaryAlbumTypeIntConverter : JsonConverter, IConverter + { + public object FromDB(ConverterContext context) + { + if (context.DbValue == DBNull.Value) + { + return SecondaryAlbumType.Studio; + } + + var val = Convert.ToInt32(context.DbValue); + + return (SecondaryAlbumType) val; + } + + public object FromDB(ColumnMap map, object dbValue) + { + return FromDB(new ConverterContext {ColumnMap = map, DbValue = dbValue}); + } + + public object ToDB(object clrValue) + { + if (clrValue == DBNull.Value) + { + return 0; + } + + if (clrValue as SecondaryAlbumType == null) + { + throw new InvalidOperationException("Attempted to save a albumtype that isn't really a albumtype"); + } + + var language = (SecondaryAlbumType) clrValue; + return (int) language; + } + + public Type DbType => typeof(int); + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(SecondaryAlbumType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, + JsonSerializer serializer) + { + var item = reader.Value; + return (SecondaryAlbumType) Convert.ToInt32(item); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(ToDB(value)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs b/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs new file mode 100644 index 000000000..8f226de3f --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/005_metadata_profiles.cs @@ -0,0 +1,25 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(5)] + public class metadata_profiles : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("MetadataProfiles") + .WithColumn("Name").AsString().Unique() + .WithColumn("PrimaryAlbumTypes").AsString() + .WithColumn("SecondaryAlbumTypes").AsString(); + + Alter.Table("Artists").AddColumn("MetadataProfileId").AsInt32().WithDefaultValue(1); + + Delete.Column("PrimaryAlbumTypes").FromTable("Artists"); + Delete.Column("SecondaryAlbumTypes").FromTable("Artists"); + + Alter.Table("Albums").AddColumn("SecondaryTypes").AsString().Nullable(); + + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 1abb1341f..7609ee825 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -18,6 +18,8 @@ using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.Notifications; using NzbDrone.Core.Organizer; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Languages; +using NzbDrone.Core.Profiles.Metadata; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Qualities; using NzbDrone.Core.Restrictions; @@ -26,6 +28,7 @@ using NzbDrone.Core.ArtistStats; using NzbDrone.Core.Tags; using NzbDrone.Core.ThingiProvider; using NzbDrone.Common.Disk; +using NzbDrone.Common.Serializer; using NzbDrone.Core.Authentication; using NzbDrone.Core.Extras.Metadata; using NzbDrone.Core.Extras.Metadata.Files; @@ -34,7 +37,6 @@ using NzbDrone.Core.Extras.Lyrics; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Music; using NzbDrone.Core.Languages; -using NzbDrone.Core.Profiles.Languages; namespace NzbDrone.Core.Datastore { @@ -71,13 +73,14 @@ namespace NzbDrone.Core.Datastore .Ignore(d => d.Tags); Mapper.Entity().RegisterModel("History") - .AutoMapChildModels(); + .AutoMapChildModels(); Mapper.Entity().RegisterModel("Artists") - .Ignore(s => s.RootFolderPath) - .Relationship() - .HasOne(a => a.Profile, a => a.ProfileId) - .HasOne(s => s.LanguageProfile, s => s.LanguageProfileId); + .Ignore(s => s.RootFolderPath) + .Relationship() + .HasOne(a => a.Profile, a => a.ProfileId) + .HasOne(s => s.LanguageProfile, s => s.LanguageProfileId) + .HasOne(s => s.MetadataProfile, s => s.MetadataProfileId); Mapper.Entity().RegisterModel("Albums"); @@ -104,6 +107,7 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("Profiles"); Mapper.Entity().RegisterModel("LanguageProfiles"); + Mapper.Entity().RegisterModel("MetadataProfiles"); Mapper.Entity().RegisterModel("Logs"); Mapper.Entity().RegisterModel("NamingConfig"); Mapper.Entity().MapResultSet(); @@ -146,6 +150,8 @@ namespace NzbDrone.Core.Datastore MapRepository.Instance.RegisterTypeConverter(typeof(Language), new LanguageIntConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new LanguageIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new PrimaryAlbumTypeIntConverter())); + MapRepository.Instance.RegisterTypeConverter(typeof(List), new EmbeddedDocumentConverter(new SecondaryAlbumTypeIntConverter())); MapRepository.Instance.RegisterTypeConverter(typeof(ParsedAlbumInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ParsedTrackInfo), new EmbeddedDocumentConverter()); MapRepository.Instance.RegisterTypeConverter(typeof(ReleaseInfo), new EmbeddedDocumentConverter()); diff --git a/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs index f4f67652d..a83b87b6d 100644 --- a/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs +++ b/src/NzbDrone.Core/MetadataSource/IProvideArtistInfo.cs @@ -1,11 +1,12 @@ using NzbDrone.Core.Music; using System; using System.Collections.Generic; +using NzbDrone.Core.Profiles.Metadata; namespace NzbDrone.Core.MetadataSource { public interface IProvideArtistInfo { - Tuple> GetArtistInfo(string lidarrId, List primaryAlbumTypes, List secondaryAlbumTypes); + Tuple> GetArtistInfo(string lidarrId, int metadataProfileId); } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs index 3ea236574..ababb59d0 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs @@ -22,6 +22,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource public List Genres { get; set; } public List Labels { get; set; } public string Type { get; set; } + public List SecondaryTypes { get; set; } public List Media { get; set; } public List Tracks { get; set; } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 43965466a..e92e5bb8b 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -14,6 +14,7 @@ using NzbDrone.Core.Music; using Newtonsoft.Json; using NzbDrone.Core.Configuration; using System.Text.RegularExpressions; +using NzbDrone.Core.Profiles.Metadata; namespace NzbDrone.Core.MetadataSource.SkyHook { @@ -25,39 +26,36 @@ namespace NzbDrone.Core.MetadataSource.SkyHook private readonly IArtistService _artistService; private readonly IHttpRequestBuilderFactory _requestBuilder; private readonly IConfigService _configService; + private readonly IMetadataProfileService _metadataProfileService; private IHttpRequestBuilderFactory customerRequestBuilder; - public SkyHookProxy(IHttpClient httpClient, ILidarrCloudRequestBuilder requestBuilder, IArtistService artistService, Logger logger, IConfigService configService) + public SkyHookProxy(IHttpClient httpClient, ILidarrCloudRequestBuilder requestBuilder, IArtistService artistService, Logger logger, IConfigService configService, IMetadataProfileService metadataProfileService) { _httpClient = httpClient; _configService = configService; + _metadataProfileService = metadataProfileService; _requestBuilder = requestBuilder.Search; _artistService = artistService; _logger = logger; } - public Tuple> GetArtistInfo(string foreignArtistId, List primaryAlbumTypes, List secondaryAlbumTypes) + public Tuple> GetArtistInfo(string foreignArtistId, int metadataProfileId) { _logger.Debug("Getting Artist with LidarrAPI.MetadataID of {0}", foreignArtistId); SetCustomProvider(); - if (primaryAlbumTypes == null) - { - primaryAlbumTypes = new List(); - } + var metadataProfile = _metadataProfileService.Get(metadataProfileId); - if (secondaryAlbumTypes == null) - { - secondaryAlbumTypes = new List(); - } + var primaryTypes = metadataProfile.PrimaryAlbumTypes.Where(s => s.Allowed).Select(s => s.PrimaryAlbumType.Name); + var secondaryTypes = metadataProfile.SecondaryAlbumTypes.Where(s => s.Allowed).Select(s => s.SecondaryAlbumType.Name); var httpRequest = customerRequestBuilder.Create() .SetSegment("route", "artists/" + foreignArtistId) - .AddQueryParam("primTypes", string.Join("|",primaryAlbumTypes)) - .AddQueryParam("secTypes", string.Join("|", secondaryAlbumTypes)) + .AddQueryParam("primTypes", string.Join("|", primaryTypes)) + .AddQueryParam("secTypes", string.Join("|", secondaryTypes)) .Build(); httpRequest.AllowAutoRedirect = true; @@ -102,7 +100,8 @@ namespace NzbDrone.Core.MetadataSource.SkyHook try { - return new List { GetArtistInfo(slug, new List{"Album"}, new List{"Studio"}).Item1 }; + var metadataProfile = _metadataProfileService.All().First().Id; //Change this to Use last Used profile? + return new List { GetArtistInfo(slug, metadataProfile).Item1 }; } catch (ArtistNotFoundException) { @@ -160,6 +159,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook album.Media = resource.Media.Select(MapMedium).ToList(); album.Tracks = resource.Tracks.Select(MapTrack).ToList(); + album.SecondaryTypes = resource.SecondaryTypes.Select(MapSecondaryTypes).ToList(); return album; @@ -297,6 +297,35 @@ namespace NzbDrone.Core.MetadataSource.SkyHook } } + private static SecondaryAlbumType MapSecondaryTypes(string albumType) + { + switch (albumType.ToLowerInvariant()) + { + case "compilation": + return SecondaryAlbumType.Compilation; + case "soundtrack": + return SecondaryAlbumType.Soundtrack; + case "spokenword": + return SecondaryAlbumType.Spokenword; + case "interview": + return SecondaryAlbumType.Interview; + case "audiobook": + return SecondaryAlbumType.Audiobook; + case "live": + return SecondaryAlbumType.Live; + case "remix": + return SecondaryAlbumType.Remix; + case "dj-mix": + return SecondaryAlbumType.DJMix; + case "mixtape/street": + return SecondaryAlbumType.Mixtape; + case "demo": + return SecondaryAlbumType.Demo; + default: + return SecondaryAlbumType.Studio; + } + } + private void SetCustomProvider() { if (_configService.MetadataSource.IsNotNullOrWhiteSpace()) diff --git a/src/NzbDrone.Core/Music/AddArtistService.cs b/src/NzbDrone.Core/Music/AddArtistService.cs index 7b5df047a..a2a0ffdc2 100644 --- a/src/NzbDrone.Core/Music/AddArtistService.cs +++ b/src/NzbDrone.Core/Music/AddArtistService.cs @@ -87,7 +87,7 @@ namespace NzbDrone.Core.Music try { - tuple = _artistInfo.GetArtistInfo(newArtist.ForeignArtistId, newArtist.PrimaryAlbumTypes, newArtist.SecondaryAlbumTypes); + tuple = _artistInfo.GetArtistInfo(newArtist.ForeignArtistId, newArtist.MetadataProfileId); } catch (ArtistNotFoundException) { diff --git a/src/NzbDrone.Core/Music/Album.cs b/src/NzbDrone.Core/Music/Album.cs index 8430feafa..74cb25cb0 100644 --- a/src/NzbDrone.Core/Music/Album.cs +++ b/src/NzbDrone.Core/Music/Album.cs @@ -38,6 +38,7 @@ namespace NzbDrone.Core.Music public DateTime? LastDiskSync { get; set; } public DateTime Added { get; set; } public String AlbumType { get; set; } + public List SecondaryTypes { get; set; } //public string ArtworkUrl { get; set; } //public string Explicitness { get; set; } public AddArtistOptions AddOptions { get; set; } diff --git a/src/NzbDrone.Core/Music/Artist.cs b/src/NzbDrone.Core/Music/Artist.cs index bd7be5695..7fceaafa8 100644 --- a/src/NzbDrone.Core/Music/Artist.cs +++ b/src/NzbDrone.Core/Music/Artist.cs @@ -3,6 +3,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Languages; +using NzbDrone.Core.Profiles.Metadata; using NzbDrone.Core.Music; using System; using System.Collections.Generic; @@ -36,8 +37,6 @@ namespace NzbDrone.Core.Music public string Overview { get; set; } public string Disambiguation { get; set; } public string ArtistType { get; set; } - public List PrimaryAlbumTypes { get; set; } - public List SecondaryAlbumTypes { get; set; } public bool Monitored { get; set; } public bool AlbumFolder { get; set; } public DateTime? LastInfoSync { get; set; } @@ -51,8 +50,10 @@ namespace NzbDrone.Core.Music public DateTime Added { get; set; } public LazyLoaded Profile { get; set; } public LazyLoaded LanguageProfile { get; set; } + public LazyLoaded MetadataProfile { get; set; } public int ProfileId { get; set; } public int LanguageProfileId { get; set; } + public int MetadataProfileId { get; set; } public List Albums { get; set; } public HashSet Tags { get; set; } public AddArtistOptions AddOptions { get; set; } @@ -73,10 +74,9 @@ namespace NzbDrone.Core.Music Profile = otherArtist.Profile; LanguageProfileId = otherArtist.LanguageProfileId; + MetadataProfileId = otherArtist.MetadataProfileId; Albums = otherArtist.Albums; - PrimaryAlbumTypes = otherArtist.PrimaryAlbumTypes; - SecondaryAlbumTypes = otherArtist.SecondaryAlbumTypes; ProfileId = otherArtist.ProfileId; Tags = otherArtist.Tags; diff --git a/src/NzbDrone.Core/Music/ArtistEditedService.cs b/src/NzbDrone.Core/Music/ArtistEditedService.cs index cc7d062ba..c0560582a 100644 --- a/src/NzbDrone.Core/Music/ArtistEditedService.cs +++ b/src/NzbDrone.Core/Music/ArtistEditedService.cs @@ -17,7 +17,7 @@ namespace NzbDrone.Core.Music public void Handle(ArtistEditedEvent message) { // Refresh Artist is we change AlbumType Preferences - if (message.Artist.PrimaryAlbumTypes != message.OldArtist.PrimaryAlbumTypes || message.Artist.SecondaryAlbumTypes != message.OldArtist.SecondaryAlbumTypes) + if (message.Artist.LanguageProfileId != message.OldArtist.LanguageProfileId) { _commandQueueManager.Push(new RefreshArtistCommand(message.Artist.Id)); } diff --git a/src/NzbDrone.Core/Music/PrimaryAlbumType.cs b/src/NzbDrone.Core/Music/PrimaryAlbumType.cs new file mode 100644 index 000000000..399587734 --- /dev/null +++ b/src/NzbDrone.Core/Music/PrimaryAlbumType.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class PrimaryAlbumType : IEmbeddedDocument, IEquatable + { + public int Id { get; set; } + public string Name { get; set; } + + public PrimaryAlbumType() + { + } + + private PrimaryAlbumType(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(PrimaryAlbumType other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + return ReferenceEquals(this, obj) || Equals(obj as PrimaryAlbumType); + } + + public static bool operator ==(PrimaryAlbumType left, PrimaryAlbumType right) + { + return Equals(left, right); + } + + public static bool operator !=(PrimaryAlbumType left, PrimaryAlbumType right) + { + return !Equals(left, right); + } + + public static PrimaryAlbumType Album => new PrimaryAlbumType(0, "Album"); + public static PrimaryAlbumType EP => new PrimaryAlbumType(1, "EP"); + public static PrimaryAlbumType Single => new PrimaryAlbumType(2, "Single"); + public static PrimaryAlbumType Broadcast => new PrimaryAlbumType(3, "Broadcast"); + public static PrimaryAlbumType Other => new PrimaryAlbumType(4, "Other"); + + + public static readonly List All = new List + { + Album, + EP, + Single, + Broadcast, + Other + }; + + + public static PrimaryAlbumType FindById(int id) + { + if (id == 0) + { + return Album; + } + + PrimaryAlbumType albumType = All.FirstOrDefault(v => v.Id == id); + + if (albumType == null) + { + throw new ArgumentException(@"ID does not match a known album type", nameof(id)); + } + + return albumType; + } + + public static explicit operator PrimaryAlbumType(int id) + { + return FindById(id); + } + + public static explicit operator int(PrimaryAlbumType albumType) + { + return albumType.Id; + } + + public static explicit operator PrimaryAlbumType(string type) + { + var albumType = All.FirstOrDefault(v => v.Name.Equals(type, StringComparison.InvariantCultureIgnoreCase)); + + if (albumType == null) + { + throw new ArgumentException(@"Type does not match a known album type", nameof(type)); + } + + return albumType; + } + } +} diff --git a/src/NzbDrone.Core/Music/RefreshAlbumService.cs b/src/NzbDrone.Core/Music/RefreshAlbumService.cs index bffdce47a..abd6220a4 100644 --- a/src/NzbDrone.Core/Music/RefreshAlbumService.cs +++ b/src/NzbDrone.Core/Music/RefreshAlbumService.cs @@ -39,7 +39,6 @@ namespace NzbDrone.Core.Music var failCount = 0; var existingAlbums = _albumService.GetAlbumsByArtist(artist.Id); - var albums = artist.Albums; var updateList = new List(); var newList = new List(); @@ -79,6 +78,7 @@ namespace NzbDrone.Core.Music albumToUpdate.CleanTitle = Parser.Parser.CleanArtistName(albumToUpdate.Title); albumToUpdate.ArtistId = artist.Id; albumToUpdate.AlbumType = album.AlbumType; + albumToUpdate.SecondaryTypes = album.SecondaryTypes; albumToUpdate.Genres = album.Genres; albumToUpdate.Media = album.Media; albumToUpdate.Label = album.Label; diff --git a/src/NzbDrone.Core/Music/RefreshArtistService.cs b/src/NzbDrone.Core/Music/RefreshArtistService.cs index d765e1e61..3c3e42790 100644 --- a/src/NzbDrone.Core/Music/RefreshArtistService.cs +++ b/src/NzbDrone.Core/Music/RefreshArtistService.cs @@ -54,7 +54,7 @@ namespace NzbDrone.Core.Music try { - tuple = _artistInfo.GetArtistInfo(artist.ForeignArtistId, artist.PrimaryAlbumTypes, artist.SecondaryAlbumTypes); + tuple = _artistInfo.GetArtistInfo(artist.ForeignArtistId, artist.MetadataProfileId); } catch (ArtistNotFoundException) { diff --git a/src/NzbDrone.Core/Music/SecondaryAlbumType.cs b/src/NzbDrone.Core/Music/SecondaryAlbumType.cs new file mode 100644 index 000000000..98b47954a --- /dev/null +++ b/src/NzbDrone.Core/Music/SecondaryAlbumType.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class SecondaryAlbumType : IEmbeddedDocument, IEquatable + { + public int Id { get; set; } + public string Name { get; set; } + + public SecondaryAlbumType() + { + } + + private SecondaryAlbumType(int id, string name) + { + Id = id; + Name = name; + } + + public override string ToString() + { + return Name; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + + public bool Equals(SecondaryAlbumType other) + { + if (ReferenceEquals(null, other)) + { + return false; + } + if (ReferenceEquals(this, other)) + { + return true; + } + return Id.Equals(other.Id); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) + { + return false; + } + return ReferenceEquals(this, obj) || Equals(obj as SecondaryAlbumType); + } + + public static bool operator ==(SecondaryAlbumType left, SecondaryAlbumType right) + { + return Equals(left, right); + } + + public static bool operator !=(SecondaryAlbumType left, SecondaryAlbumType right) + { + return !Equals(left, right); + } + + public static SecondaryAlbumType Studio => new SecondaryAlbumType(0, "Studio"); + public static SecondaryAlbumType Compilation => new SecondaryAlbumType(1, "Compilation"); + public static SecondaryAlbumType Soundtrack => new SecondaryAlbumType(2, "Soundtrack"); + public static SecondaryAlbumType Spokenword => new SecondaryAlbumType(3, "Spokenword"); + public static SecondaryAlbumType Interview => new SecondaryAlbumType(4, "Interview"); + public static SecondaryAlbumType Audiobook => new SecondaryAlbumType(5, "Audiobook"); + public static SecondaryAlbumType Live => new SecondaryAlbumType(6, "Live"); + public static SecondaryAlbumType Remix => new SecondaryAlbumType(7, "Remix"); + public static SecondaryAlbumType DJMix => new SecondaryAlbumType(8, "DJ-Mix"); + public static SecondaryAlbumType Mixtape => new SecondaryAlbumType(9, "Mixtape/Street"); + public static SecondaryAlbumType Demo => new SecondaryAlbumType(10, "Demo"); + + + public static readonly List All = new List + { + Studio, + Compilation, + Soundtrack, + Spokenword, + Interview, + Live, + Remix, + DJMix, + Mixtape + }; + + + public static SecondaryAlbumType FindById(int id) + { + if (id == 0) + { + return Studio; + } + + SecondaryAlbumType albumType = All.FirstOrDefault(v => v.Id == id); + + if (albumType == null) + { + throw new ArgumentException(@"ID does not match a known album type", nameof(id)); + } + + return albumType; + } + + public static explicit operator SecondaryAlbumType(int id) + { + return FindById(id); + } + + public static explicit operator int(SecondaryAlbumType albumType) + { + return albumType.Id; + } + + public static explicit operator SecondaryAlbumType(string type) + { + var albumType = All.FirstOrDefault(v => v.Name.Equals(type, StringComparison.InvariantCultureIgnoreCase)); + + if (albumType == null) + { + throw new ArgumentException(@"Type does not match a known album type", nameof(type)); + } + + return albumType; + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 12814f47e..b64168632 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -150,6 +150,8 @@ + + @@ -172,6 +174,7 @@ + @@ -745,6 +748,8 @@ + + @@ -863,6 +868,12 @@ + + + + + + diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs new file mode 100644 index 000000000..7b0023cc9 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfile.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Indexers; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class MetadataProfile : ModelBase + { + public string Name { get; set; } + public List PrimaryAlbumTypes { get; set; } + public List SecondaryAlbumTypes { get; set; } + + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs new file mode 100644 index 000000000..b7849d231 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileInUseException.cs @@ -0,0 +1,13 @@ +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class MetadataProfileInUseException : NzbDroneException + { + public MetadataProfileInUseException(int profileId) + : base("Metadata profile [{0}] is in use.", profileId) + { + + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs new file mode 100644 index 000000000..df725610d --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileRepository.cs @@ -0,0 +1,23 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public interface IMetadataProfileRepository : IBasicRepository + { + bool Exists(int id); + } + + public class MetadataProfileRepository : BasicRepository, IMetadataProfileRepository + { + public MetadataProfileRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + + public bool Exists(int id) + { + return DataMapper.Query().Where(p => p.Id == id).GetRowCount() == 1; + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs new file mode 100644 index 000000000..01ac7dc13 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/MetadataProfileService.cs @@ -0,0 +1,102 @@ +using NLog; +using NzbDrone.Core.Lifecycle; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public interface IMetadataProfileService + { + MetadataProfile Add(MetadataProfile profile); + void Update(MetadataProfile profile); + void Delete(int id); + List All(); + MetadataProfile Get(int id); + bool Exists(int id); + } + + public class MetadataProfileService : IMetadataProfileService, IHandle + { + private readonly IMetadataProfileRepository _profileRepository; + private readonly IArtistService _artistService; + private readonly Logger _logger; + + public MetadataProfileService(IMetadataProfileRepository profileRepository, IArtistService artistService, Logger logger) + { + _profileRepository = profileRepository; + _artistService = artistService; + _logger = logger; + } + + public MetadataProfile Add(MetadataProfile profile) + { + return _profileRepository.Insert(profile); + } + + public void Update(MetadataProfile profile) + { + _profileRepository.Update(profile); + } + + public void Delete(int id) + { + if (_artistService.GetAllArtists().Any(c => c.MetadataProfileId == id)) + { + throw new MetadataProfileInUseException(id); + } + + _profileRepository.Delete(id); + } + + public List All() + { + return _profileRepository.All().ToList(); + } + + public MetadataProfile Get(int id) + { + return _profileRepository.Get(id); + } + + public bool Exists(int id) + { + return _profileRepository.Exists(id); + } + + private void AddDefaultProfile(string name, List primAllowed, List secAllowed) + { + var primaryTypes = PrimaryAlbumType.All + .OrderByDescending(l => l.Name) + .Select(v => new ProfilePrimaryAlbumTypeItem { PrimaryAlbumType = v, Allowed = primAllowed.Contains(v) }) + .ToList(); + + var secondaryTypes = SecondaryAlbumType.All + .OrderByDescending(l => l.Name) + .Select(v => new ProfileSecondaryAlbumTypeItem { SecondaryAlbumType = v, Allowed = secAllowed.Contains(v) }) + .ToList(); + + var profile = new MetadataProfile + { + Name = name, + PrimaryAlbumTypes = primaryTypes, + SecondaryAlbumTypes = secondaryTypes + }; + + Add(profile); + } + + public void Handle(ApplicationStartedEvent message) + { + if (All().Any()) + { + return; + } + + _logger.Info("Setting up default metadata profile"); + + AddDefaultProfile("Standard", new List{PrimaryAlbumType.Album}, new List{ SecondaryAlbumType.Studio }); + } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs b/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs new file mode 100644 index 000000000..6f1c008f1 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/ProfilePrimaryAlbumTypeItem.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class ProfilePrimaryAlbumTypeItem : IEmbeddedDocument + { + public PrimaryAlbumType PrimaryAlbumType { get; set; } + public bool Allowed { get; set; } + } +} diff --git a/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs b/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs new file mode 100644 index 000000000..d9aacf570 --- /dev/null +++ b/src/NzbDrone.Core/Profiles/Metadata/ProfileSecondaryAlbumTypeItem.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.Profiles.Metadata +{ + public class ProfileSecondaryAlbumTypeItem : IEmbeddedDocument + { + public SecondaryAlbumType SecondaryAlbumType { get; set; } + public bool Allowed { get; set; } + } +}