New: Import List Exclusions (#608)

* New: Import List Exclusions

* Fixed: ImportExclusion ForeignId Checks, Unique. RefreshArtist Duplicate

* Fixed: Copy/Paste typos
This commit is contained in:
Qstick 2019-03-01 17:26:36 -05:00 committed by GitHub
parent b9cc94aa46
commit 42c16c227e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1160 additions and 20 deletions

View File

@ -22,7 +22,8 @@ class DeleteArtistModalContent extends Component {
super(props, context);
this.state = {
deleteFiles: false
deleteFiles: false,
addImportListExclusion: false
};
}
@ -33,11 +34,17 @@ class DeleteArtistModalContent extends Component {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteArtistConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
this.setState({ deleteFiles: false });
this.props.onDeletePress(deleteFiles);
this.setState({ addImportListExclusion: false });
this.props.onDeletePress(deleteFiles, addImportListExclusion);
}
//
@ -57,6 +64,8 @@ class DeleteArtistModalContent extends Component {
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
let deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
let deleteFilesHelpText = 'Delete the track files and artist folder';
@ -96,6 +105,19 @@ class DeleteArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup>
<FormLabel>Add List Exclusion</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText="Prevent artist from being added to Lidarr by Import lists"
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
{
deleteFiles &&
<div className={styles.deleteFilesMessage}>

View File

@ -24,10 +24,11 @@ class DeleteArtistModalContentConnector extends Component {
//
// Listeners
onDeletePress = (deleteFiles) => {
onDeletePress = (deleteFiles, addImportListExclusion) => {
this.props.deleteArtist({
id: this.props.artistId,
deleteFiles
deleteFiles,
addImportListExclusion
});
this.props.onModalClose(true);

View File

@ -0,0 +1,27 @@
import PropTypes from 'prop-types';
import React from 'react';
import { sizes } from 'Helpers/Props';
import Modal from 'Components/Modal/Modal';
import EditImportListExclusionModalContentConnector from './EditImportListExclusionModalContentConnector';
function EditImportListExclusionModal({ isOpen, onModalClose, ...otherProps }) {
return (
<Modal
size={sizes.MEDIUM}
isOpen={isOpen}
onModalClose={onModalClose}
>
<EditImportListExclusionModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
EditImportListExclusionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default EditImportListExclusionModal;

View File

@ -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 EditImportListExclusionModal from './EditImportListExclusionModal';
function mapStateToProps() {
return {};
}
const mapDispatchToProps = {
clearPendingChanges
};
class EditImportListExclusionModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearPendingChanges({ section: 'settings.importListExclusions' });
this.props.onModalClose();
}
//
// Render
render() {
return (
<EditImportListExclusionModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
EditImportListExclusionModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(mapStateToProps, mapDispatchToProps)(EditImportListExclusionModalConnector);

View File

@ -0,0 +1,11 @@
.body {
composes: modalBody from 'Components/Modal/ModalBody.css';
flex: 1 1 430px;
}
.deleteButton {
composes: button from 'Components/Link/Button.css';
margin-right: auto;
}

View File

@ -0,0 +1,135 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import { stringSettingShape } from 'Helpers/Props/Shapes/settingShape';
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 styles from './EditImportListExclusionModalContent.css';
function EditImportListExclusionModalContent(props) {
const {
id,
isFetching,
error,
isSaving,
saveError,
item,
onInputChange,
onSavePress,
onModalClose,
onDeleteImportListExclusionPress,
...otherProps
} = props;
const {
artistName,
foreignId
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Import List Exclusion' : 'Add Import List Exclusion'}
</ModalHeader>
<ModalBody className={styles.body}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new import list exclusion, please try again.</div>
}
{
!isFetching && !error &&
<Form
{...otherProps}
>
<FormGroup>
<FormLabel>Artist Name</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="artistName"
helpText="The name of the artist to exclude (can be anything meaningful)"
{...artistName}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Musicbrainz Id</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="foreignId"
helpText="The Musicbrainz Id of the artist to exclude"
{...foreignId}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteImportListExclusionPress}
>
Delete
</Button>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
const ImportListExclusionShape = {
artistName: PropTypes.shape(stringSettingShape).isRequired,
foreignId: PropTypes.shape(stringSettingShape).isRequired
};
EditImportListExclusionModalContent.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.shape(ImportListExclusionShape).isRequired,
onInputChange: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteImportListExclusionPress: PropTypes.func
};
export default EditImportListExclusionModalContent;

View File

@ -0,0 +1,118 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import selectSettings from 'Store/Selectors/selectSettings';
import { setImportListExclusionValue, saveImportListExclusion } from 'Store/Actions/settingsActions';
import EditImportListExclusionModalContent from './EditImportListExclusionModalContent';
const newImportListExclusion = {
artistName: '',
foreignId: ''
};
function createImportListExclusionSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.importListExclusions,
(id, importListExclusions) => {
const {
isFetching,
error,
isSaving,
saveError,
pendingChanges,
items
} = importListExclusions;
const mapping = id ? _.find(items, { id }) : newImportListExclusion;
const settings = selectSettings(mapping, pendingChanges, saveError);
return {
id,
isFetching,
error,
isSaving,
saveError,
item: settings.settings,
...settings
};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportListExclusionSelector(),
(importListExclusion) => {
return {
...importListExclusion
};
}
);
}
const mapDispatchToProps = {
setImportListExclusionValue,
saveImportListExclusion
};
class EditImportListExclusionModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
if (!this.props.id) {
Object.keys(newImportListExclusion).forEach((name) => {
this.props.setImportListExclusionValue({
name,
value: newImportListExclusion[name]
});
});
}
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportListExclusionValue({ name, value });
}
onSavePress = () => {
this.props.saveImportListExclusion({ id: this.props.id });
}
//
// Render
render() {
return (
<EditImportListExclusionModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditImportListExclusionModalContentConnector.propTypes = {
id: PropTypes.number,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setImportListExclusionValue: PropTypes.func.isRequired,
saveImportListExclusion: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditImportListExclusionModalContentConnector);

View File

@ -0,0 +1,23 @@
.importListExclusion {
display: flex;
align-items: stretch;
margin-bottom: 10px;
height: 30px;
border-bottom: 1px solid $borderColor;
line-height: 30px;
}
.artistName {
flex: 0 0 300px;
}
.foreignId {
flex: 0 0 400px;
}
.actions {
display: flex;
justify-content: flex-end;
flex: 1 0 auto;
padding-right: 10px;
}

View File

@ -0,0 +1,111 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import classNames from 'classnames';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import styles from './ImportListExclusion.css';
class ImportListExclusion extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: false
};
}
//
// Listeners
onEditImportListExclusionPress = () => {
this.setState({ isEditImportListExclusionModalOpen: true });
}
onEditImportListExclusionModalClose = () => {
this.setState({ isEditImportListExclusionModalOpen: false });
}
onDeleteImportListExclusionPress = () => {
this.setState({
isEditImportListExclusionModalOpen: false,
isDeleteImportListExclusionModalOpen: true
});
}
onDeleteImportListExclusionModalClose = () => {
this.setState({ isDeleteImportListExclusionModalOpen: false });
}
onConfirmDeleteImportListExclusion = () => {
this.props.onConfirmDeleteImportListExclusion(this.props.id);
}
//
// Render
render() {
const {
id,
artistName,
foreignId
} = this.props;
return (
<div
className={classNames(
styles.importListExclusion,
)}
>
<div className={styles.artistName}>{artistName}</div>
<div className={styles.foreignId}>{foreignId}</div>
<div className={styles.actions}>
<Link
onPress={this.onEditImportListExclusionPress}
>
<Icon name={icons.EDIT} />
</Link>
</div>
<EditImportListExclusionModalConnector
id={id}
isOpen={this.state.isEditImportListExclusionModalOpen}
onModalClose={this.onEditImportListExclusionModalClose}
onDeleteImportListExclusionPress={this.onDeleteImportListExclusionPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteImportListExclusionModalOpen}
kind={kinds.DANGER}
title="Delete Import List Exclusion"
message="Are you sure you want to delete this import list exclusion?"
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteImportListExclusion}
onCancel={this.onDeleteImportListExclusionModalClose}
/>
</div>
);
}
}
ImportListExclusion.propTypes = {
id: PropTypes.number.isRequired,
artistName: PropTypes.string.isRequired,
foreignId: PropTypes.string.isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
ImportListExclusion.defaultProps = {
// The drag preview will not connect the drag handle.
connectDragSource: (node) => node
};
export default ImportListExclusion;

View File

@ -0,0 +1,23 @@
.importListExclusionsHeader {
display: flex;
margin-bottom: 10px;
font-weight: bold;
}
.host {
flex: 0 0 300px;
}
.path {
flex: 0 0 400px;
}
.addImportListExclusion {
display: flex;
justify-content: flex-end;
padding-right: 10px;
}
.addButton {
text-align: center;
}

View File

@ -0,0 +1,100 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import PageSectionContent from 'Components/Page/PageSectionContent';
import ImportListExclusion from './ImportListExclusion';
import EditImportListExclusionModalConnector from './EditImportListExclusionModalConnector';
import styles from './ImportListExclusions.css';
class ImportListExclusions extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddImportListExclusionModalOpen: false
};
}
//
// Listeners
onAddImportListExclusionPress = () => {
this.setState({ isAddImportListExclusionModalOpen: true });
}
onModalClose = () => {
this.setState({ isAddImportListExclusionModalOpen: false });
}
//
// Render
render() {
const {
items,
onConfirmDeleteImportListExclusion,
...otherProps
} = this.props;
return (
<FieldSet legend="Import List Exclusions">
<PageSectionContent
errorMessage="Unable to load Import List Exclusions"
{...otherProps}
>
<div className={styles.importListExclusionsHeader}>
<div className={styles.host}>Name</div>
<div className={styles.path}>Foreign Id</div>
</div>
<div>
{
items.map((item, index) => {
return (
<ImportListExclusion
key={item.id}
{...item}
{...otherProps}
index={index}
onConfirmDeleteImportListExclusion={onConfirmDeleteImportListExclusion}
/>
);
})
}
</div>
<div className={styles.addImportListExclusion}>
<Link
className={styles.addButton}
onPress={this.onAddImportListExclusionPress}
>
<Icon name={icons.ADD} />
</Link>
</div>
<EditImportListExclusionModalConnector
isOpen={this.state.isAddImportListExclusionModalOpen}
onModalClose={this.onModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
ImportListExclusions.propTypes = {
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteImportListExclusion: PropTypes.func.isRequired
};
export default ImportListExclusions;

View File

@ -0,0 +1,59 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchImportListExclusions, deleteImportListExclusion } from 'Store/Actions/settingsActions';
import ImportListExclusions from './ImportListExclusions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.importListExclusions,
(importListExclusions) => {
return {
...importListExclusions
};
}
);
}
const mapDispatchToProps = {
fetchImportListExclusions,
deleteImportListExclusion
};
class ImportListExclusionsConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchImportListExclusions();
}
//
// Listeners
onConfirmDeleteImportListExclusion = (id) => {
this.props.deleteImportListExclusion({ id });
}
//
// Render
render() {
return (
<ImportListExclusions
{...this.state}
{...this.props}
onConfirmDeleteImportListExclusion={this.onConfirmDeleteImportListExclusion}
/>
);
}
}
ImportListExclusionsConnector.propTypes = {
fetchImportListExclusions: PropTypes.func.isRequired,
deleteImportListExclusion: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListExclusionsConnector);

View File

@ -7,6 +7,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ImportListsExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector';
class ImportListSettings extends Component {
@ -74,6 +75,7 @@ class ImportListSettings extends Component {
<PageContentBodyConnector>
<ImportListsConnector />
<ImportListsExclusionsConnector />
</PageContentBodyConnector>
</PageContent>
);

View File

@ -0,0 +1,69 @@
import { createAction } from 'redux-actions';
import { createThunk } from 'Store/thunks';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
//
// Variables
const section = 'settings.importListExclusions';
//
// Actions Types
export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
//
// Action Creators
export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
items: [],
isSaving: false,
saveError: null,
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_IMPORT_LIST_EXCLUSIONS]: createFetchHandler(section, '/importlistexclusion'),
[SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
},
//
// Reducers
reducers: {
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section)
}
};

View File

@ -186,7 +186,8 @@ export const deleteArtist = createThunk(DELETE_ARTIST, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles
deleteFiles: payload.deleteFiles,
addImportListExclusion: payload.addImportListExclusion
}
};
});

View File

@ -9,6 +9,7 @@ import indexerOptions from './Settings/indexerOptions';
import indexers from './Settings/indexers';
import languageProfiles from './Settings/languageProfiles';
import importLists from './Settings/importLists';
import importListExclusions from './Settings/importListExclusions';
import metadataProfiles from './Settings/metadataProfiles';
import mediaManagement from './Settings/mediaManagement';
import metadata from './Settings/metadata';
@ -27,6 +28,7 @@ export * from './Settings/downloadClients';
export * from './Settings/downloadClientOptions';
export * from './Settings/general';
export * from './Settings/importLists';
export * from './Settings/importListExclusions';
export * from './Settings/indexerOptions';
export * from './Settings/indexers';
export * from './Settings/languageProfiles';
@ -62,6 +64,7 @@ export const defaultState = {
indexers: indexers.defaultState,
languageProfiles: languageProfiles.defaultState,
importLists: importLists.defaultState,
importListExclusions: importListExclusions.defaultState,
metadataProfiles: metadataProfiles.defaultState,
mediaManagement: mediaManagement.defaultState,
metadata: metadata.defaultState,
@ -102,6 +105,7 @@ export const actionHandlers = handleThunks({
...indexers.actionHandlers,
...languageProfiles.actionHandlers,
...importLists.actionHandlers,
...importListExclusions.actionHandlers,
...metadataProfiles.actionHandlers,
...mediaManagement.actionHandlers,
...metadata.actionHandlers,
@ -133,6 +137,7 @@ export const reducers = createHandleActions({
...indexers.reducers,
...languageProfiles.reducers,
...importLists.reducers,
...importListExclusions.reducers,
...metadataProfiles.reducers,
...mediaManagement.reducers,
...metadata.reducers,

View File

@ -171,8 +171,9 @@ namespace Lidarr.Api.V1.Artist
private void DeleteArtist(int id)
{
var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles");
var addImportListExclusion = Request.GetBooleanQueryParameter("addImportListExclusion");
_artistService.DeleteArtist(id, deleteFiles);
_artistService.DeleteArtist(id, deleteFiles, addImportListExclusion);
}
private void MapCoversToLocal(params ArtistResource[] artists)

View File

@ -0,0 +1,56 @@
using System.Collections.Generic;
using NzbDrone.Core.ImportLists.Exclusions;
using Lidarr.Http;
using FluentValidation;
using NzbDrone.Core.Validation;
namespace Lidarr.Api.V1.ImportLists
{
public class ImportListExclusionModule : LidarrRestModule<ImportListExclusionResource>
{
private readonly IImportListExclusionService _importListExclusionService;
public ImportListExclusionModule(IImportListExclusionService importListExclusionService,
ImportListExclusionExistsValidator importListExclusionExistsValidator,
GuidValidator guidValidator)
{
_importListExclusionService = importListExclusionService;
GetResourceById = GetImportListExclusion;
GetResourceAll = GetImportListExclusions;
CreateResource = AddImportListExclusion;
UpdateResource = UpdateImportListExclusion;
DeleteResource = DeleteImportListExclusionResource;
SharedValidator.RuleFor(c => c.ForeignId).NotEmpty().SetValidator(guidValidator).SetValidator(importListExclusionExistsValidator);
SharedValidator.RuleFor(c => c.ArtistName).NotEmpty();
}
private ImportListExclusionResource GetImportListExclusion(int id)
{
return _importListExclusionService.Get(id).ToResource();
}
private List<ImportListExclusionResource> GetImportListExclusions()
{
return _importListExclusionService.All().ToResource();
}
private int AddImportListExclusion(ImportListExclusionResource resource)
{
var customFilter = _importListExclusionService.Add(resource.ToModel());
return customFilter.Id;
}
private void UpdateImportListExclusion(ImportListExclusionResource resource)
{
_importListExclusionService.Update(resource.ToModel());
}
private void DeleteImportListExclusionResource(int id)
{
_importListExclusionService.Delete(id);
}
}
}

View File

@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.ImportLists.Exclusions;
using Lidarr.Http.REST;
namespace Lidarr.Api.V1.ImportLists
{
public class ImportListExclusionResource : RestResource
{
public string ForeignId { get; set; }
public string ArtistName { get; set; }
}
public static class ImportListExclusionResourceMapper
{
public static ImportListExclusionResource ToResource(this ImportListExclusion model)
{
if (model == null) return null;
return new ImportListExclusionResource
{
Id = model.Id,
ForeignId = model.ForeignId,
ArtistName = model.Name,
};
}
public static ImportListExclusion ToModel(this ImportListExclusionResource resource)
{
if (resource == null) return null;
return new ImportListExclusion
{
Id = resource.Id,
ForeignId = resource.ForeignId,
Name = resource.ArtistName
};
}
public static List<ImportListExclusionResource> ToResource(this IEnumerable<ImportListExclusion> filters)
{
return filters.Select(ToResource).ToList();
}
}
}

View File

@ -99,6 +99,8 @@
<Compile Include="Config\MetadataProviderConfigResource.cs" />
<Compile Include="CustomFilters\CustomFilterModule.cs" />
<Compile Include="CustomFilters\CustomFilterResource.cs" />
<Compile Include="ImportLists\ImportListExclusionModule.cs" />
<Compile Include="ImportLists\ImportListExclusionResource.cs" />
<Compile Include="ImportLists\ImportListModule.cs" />
<Compile Include="ImportLists\ImportListResource.cs" />
<Compile Include="Profiles\Metadata\MetadataProfileModule.cs" />

View File

@ -7,6 +7,7 @@ using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Music;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.ImportLists.Exclusions;
namespace NzbDrone.Core.Test.ImportListTests
{
@ -43,6 +44,10 @@ namespace NzbDrone.Core.Test.ImportListTests
Mocker.GetMock<IFetchAndParseImportList>()
.Setup(v => v.Fetch())
.Returns(_importListReports);
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
.Returns(new List<ImportListExclusion>());
}
private void WithAlbum()
@ -67,6 +72,17 @@ namespace NzbDrone.Core.Test.ImportListTests
.Returns(new Artist{ForeignArtistId = _importListReports.First().ArtistMusicBrainzId });
}
private void WithExcludedArtist()
{
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
.Returns(new List<ImportListExclusion> {
new ImportListExclusion {
ForeignId = "f59c5520-5f46-4d2c-b2c4-822eabf53419"
}
});
}
[Test]
public void should_search_if_artist_title_and_no_artist_id()
{
@ -123,7 +139,7 @@ namespace NzbDrone.Core.Test.ImportListTests
}
[Test]
public void should_not_try_add_if_existing_artist()
public void should_not_add_if_existing_artist()
{
WithArtistId();
WithAlbum();
@ -149,6 +165,20 @@ namespace NzbDrone.Core.Test.ImportListTests
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 1)));
}
[Test]
public void should_not_add_if_excluded_artist()
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithExcludedArtist();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddArtistService>()
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 0)));
}
[Test]
public void should_mark_album_for_monitor_if_album_id()
{

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
@ -387,6 +387,7 @@
<Compile Include="ThingiProviderTests\ProviderStatusServiceFixture.cs" />
<Compile Include="UpdateTests\UpdatePackageProviderFixture.cs" />
<Compile Include="UpdateTests\UpdateServiceFixture.cs" />
<Compile Include="ValidationTests\GuidValidationFixture.cs" />
<Compile Include="ValidationTests\SystemFolderValidatorFixture.cs" />
<Compile Include="XbmcVersionTests.cs" />
<None Include="Files\Nzbs\NoFiles.nzb">
@ -610,9 +611,6 @@
<ItemGroup>
<IdentificationTestCases Include="Files\Identification\*.json" />
</ItemGroup>
<Copy
SourceFiles="@(IdentificationTestCases)"
DestinationFolder="$(OutputPath)\Files\Identification\"
SkipUnchangedFiles="true"/>
<Copy SourceFiles="@(IdentificationTestCases)" DestinationFolder="$(OutputPath)\Files\Identification\" SkipUnchangedFiles="true" />
</Target>
</Project>
</Project>

View File

@ -0,0 +1,44 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Validation;
using NzbDrone.Test.Common;
using NzbDrone.Core.ImportLists.Exclusions;
namespace NzbDrone.Core.Test.ValidationTests
{
public class GuidValidationFixture : CoreTest<GuidValidator>
{
private TestValidator<ImportListExclusion> _validator;
[SetUp]
public void Setup()
{
_validator = new TestValidator<ImportListExclusion>
{
v => v.RuleFor(s => s.ForeignId).SetValidator(Subject)
};
}
[Test]
public void should_not_be_valid_if_invalid_guid()
{
var listExclusion = Builder<ImportListExclusion>.CreateNew()
.With(s => s.ForeignId = "e1f1e33e-2e4c-4d43-b91b-7064068d328")
.Build();
_validator.Validate(listExclusion).IsValid.Should().BeFalse();
}
[Test]
public void should_be_valid_if_valid_guid()
{
var listExclusion = Builder<ImportListExclusion>.CreateNew()
.With(s => s.ForeignId = "e1f1e33e-2e4c-4d43-b91b-7064068d3283")
.Build();
_validator.Validate(listExclusion).IsValid.Should().BeTrue();
}
}
}

View File

@ -0,0 +1,16 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(027)]
public class add_import_exclusions : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("ImportListExclusions")
.WithColumn("ForeignId").AsString().NotNullable().Unique()
.WithColumn("Name").AsString().NotNullable();
}
}
}

View File

@ -12,6 +12,7 @@ using NzbDrone.Core.Download;
using NzbDrone.Core.Download.Pending;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.Instrumentation;
using NzbDrone.Core.Jobs;
using NzbDrone.Core.MediaFiles;
@ -191,6 +192,7 @@ namespace NzbDrone.Core.Datastore
Mapper.Entity<ImportListStatus>().RegisterModel("ImportListStatus");
Mapper.Entity<CustomFilter>().RegisterModel("CustomFilters");
Mapper.Entity<ImportListExclusion>().RegisterModel("ImportListExclusions");
}
private static void RegisterMappers()

View File

@ -0,0 +1,10 @@
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public class ImportListExclusion : ModelBase
{
public string ForeignId { get; set; }
public string Name { get; set; }
}
}

View File

@ -0,0 +1,22 @@
using FluentValidation.Validators;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public class ImportListExclusionExistsValidator : PropertyValidator
{
private readonly IImportListExclusionService _importListExclusionService;
public ImportListExclusionExistsValidator(IImportListExclusionService importListExclusionService)
: base("This exclusion has already been added.")
{
_importListExclusionService = importListExclusionService;
}
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null) return true;
return (!_importListExclusionService.All().Exists(s => s.ForeignId == context.PropertyValue.ToString()));
}
}
}

View File

@ -0,0 +1,24 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
using System.Linq;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public interface IImportListExclusionRepository : IBasicRepository<ImportListExclusion>
{
ImportListExclusion FindByForeignId(string foreignId);
}
public class ImportListExclusionRepository : BasicRepository<ImportListExclusion>, IImportListExclusionRepository
{
public ImportListExclusionRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
public ImportListExclusion FindByForeignId(string foreignId)
{
return Query.Where<ImportListExclusion>(m => m.ForeignId == foreignId).SingleOrDefault();
}
}
}

View File

@ -0,0 +1,80 @@
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music.Events;
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.ImportLists.Exclusions
{
public interface IImportListExclusionService
{
ImportListExclusion Add(ImportListExclusion importListExclusion);
List<ImportListExclusion> All();
void Delete(int id);
ImportListExclusion Get(int id);
ImportListExclusion FindByForeignId(string foreignId);
ImportListExclusion Update(ImportListExclusion importListExclusion);
}
public class ImportListExclusionService : IImportListExclusionService, IHandleAsync<ArtistDeletedEvent>
{
private readonly IImportListExclusionRepository _repo;
public ImportListExclusionService(IImportListExclusionRepository repo)
{
_repo = repo;
}
public ImportListExclusion Add(ImportListExclusion importListExclusion)
{
return _repo.Insert(importListExclusion);
}
public ImportListExclusion Update(ImportListExclusion importListExclusion)
{
return _repo.Update(importListExclusion);
}
public void Delete(int id)
{
_repo.Delete(id);
}
public ImportListExclusion Get(int id)
{
return _repo.Get(id);
}
public ImportListExclusion FindByForeignId(string foreignId)
{
return _repo.FindByForeignId(foreignId);
}
public List<ImportListExclusion> All()
{
return _repo.All().ToList();
}
public void HandleAsync(ArtistDeletedEvent message)
{
if (!message.AddImportListExclusion)
{
return;
}
var existingExclusion = _repo.FindByForeignId(message.Artist.ForeignArtistId);
if (existingExclusion != null)
{
return;
}
var importExclusion = new ImportListExclusion
{
ForeignId = message.Artist.ForeignArtistId,
Name = message.Artist.Name
};
_repo.Insert(importExclusion);
}
}
}

View File

@ -3,6 +3,7 @@ using System.Linq;
using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.MetadataSource;
@ -15,6 +16,7 @@ namespace NzbDrone.Core.ImportLists
{
private readonly IImportListStatusService _importListStatusService;
private readonly IImportListFactory _importListFactory;
private readonly IImportListExclusionService _importListExclusionService;
private readonly IFetchAndParseImportList _listFetcherAndParser;
private readonly ISearchForNewAlbum _albumSearchService;
private readonly ISearchForNewArtist _artistSearchService;
@ -25,6 +27,7 @@ namespace NzbDrone.Core.ImportLists
public ImportListSyncService(IImportListStatusService importListStatusService,
IImportListFactory importListFactory,
IImportListExclusionService importListExclusionService,
IFetchAndParseImportList listFetcherAndParser,
ISearchForNewAlbum albumSearchService,
ISearchForNewArtist artistSearchService,
@ -35,6 +38,7 @@ namespace NzbDrone.Core.ImportLists
{
_importListStatusService = importListStatusService;
_importListFactory = importListFactory;
_importListExclusionService = importListExclusionService;
_listFetcherAndParser = listFetcherAndParser;
_albumSearchService = albumSearchService;
_artistSearchService = artistSearchService;
@ -78,6 +82,8 @@ namespace NzbDrone.Core.ImportLists
var reportNumber = 1;
var listExclusions = _importListExclusionService.All();
foreach (var report in reports)
{
_logger.ProgressTrace("Processing list item {0}/{1}", reportNumber, reports.Count);
@ -112,9 +118,17 @@ namespace NzbDrone.Core.ImportLists
// Check to see if artist in DB
var existingArtist = _artistService.FindById(report.ArtistMusicBrainzId);
// Check to see if artist excluded
var excludedArtist = listExclusions.Where(s => s.ForeignId == report.ArtistMusicBrainzId).SingleOrDefault();
if (excludedArtist != null)
{
_logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.ArtistMusicBrainzId, report.Artist);
}
// Append Artist if not already in DB or already on add list
if (existingArtist == null && artistsToAdd.All(s => s.Metadata.Value.ForeignArtistId != report.ArtistMusicBrainzId))
if (existingArtist == null && excludedArtist == null && artistsToAdd.All(s => s.Metadata.Value.ForeignArtistId != report.ArtistMusicBrainzId))
{
artistsToAdd.Add(new Artist
{

View File

@ -4,9 +4,10 @@ using NzbDrone.Core.Music.Events;
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Parser;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Cache;
using NzbDrone.Core.ImportLists.Exclusions;
using NzbDrone.Core.Parser;
namespace NzbDrone.Core.Music
{
@ -21,7 +22,7 @@ namespace NzbDrone.Core.Music
Artist FindByName(string title);
Artist FindByNameInexact(string title);
List<Artist> GetCandidates(string title);
void DeleteArtist(int artistId, bool deleteFiles);
void DeleteArtist(int artistId, bool deleteFiles, bool addImportListExclusion = false);
List<Artist> GetAllArtists();
List<Artist> AllForTag(int tagId);
Artist UpdateArtist(Artist artist);
@ -36,6 +37,7 @@ namespace NzbDrone.Core.Music
private readonly IArtistMetadataRepository _artistMetadataRepository;
private readonly IEventAggregator _eventAggregator;
private readonly ITrackService _trackService;
private readonly IImportListExclusionService _importListExclusionService;
private readonly IBuildArtistPaths _artistPathBuilder;
private readonly Logger _logger;
private readonly ICached<List<Artist>> _cache;
@ -44,6 +46,7 @@ namespace NzbDrone.Core.Music
IArtistMetadataRepository artistMetadataRepository,
IEventAggregator eventAggregator,
ITrackService trackService,
IImportListExclusionService importListExclusionService,
IBuildArtistPaths artistPathBuilder,
ICacheManager cacheManager,
Logger logger)
@ -52,6 +55,7 @@ namespace NzbDrone.Core.Music
_artistMetadataRepository = artistMetadataRepository;
_eventAggregator = eventAggregator;
_trackService = trackService;
_importListExclusionService = importListExclusionService;
_artistPathBuilder = artistPathBuilder;
_cache = cacheManager.GetCache<List<Artist>>(GetType());
_logger = logger;
@ -82,12 +86,12 @@ namespace NzbDrone.Core.Music
return _artistRepository.ArtistPathExists(folder);
}
public void DeleteArtist(int artistId, bool deleteFiles)
public void DeleteArtist(int artistId, bool deleteFiles, bool addImportListExclusion = false)
{
_cache.Clear();
var artist = _artistRepository.Get(artistId);
_artistRepository.Delete(artistId);
_eventAggregator.PublishEvent(new ArtistDeletedEvent(artist, deleteFiles));
_eventAggregator.PublishEvent(new ArtistDeletedEvent(artist, deleteFiles, addImportListExclusion));
}
public Artist FindById(string spotifyId)

View File

@ -1,4 +1,4 @@
using NzbDrone.Common.Messaging;
using NzbDrone.Common.Messaging;
using System;
using System.Collections.Generic;
using System.Linq;
@ -10,11 +10,13 @@ namespace NzbDrone.Core.Music.Events
{
public Artist Artist { get; private set; }
public bool DeleteFiles { get; private set; }
public bool AddImportListExclusion { get; private set; }
public ArtistDeletedEvent(Artist artist, bool deleteFiles)
public ArtistDeletedEvent(Artist artist, bool deleteFiles, bool addImportListExclusion)
{
Artist = artist;
DeleteFiles = deleteFiles;
AddImportListExclusion = addImportListExclusion;
}
}
}

View File

@ -14,6 +14,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NzbDrone.Core.ImportLists.Exclusions;
namespace NzbDrone.Core.Music
{
@ -29,6 +30,7 @@ namespace NzbDrone.Core.Music
private readonly IDiskScanService _diskScanService;
private readonly ICheckIfArtistShouldBeRefreshed _checkIfArtistShouldBeRefreshed;
private readonly IConfigService _configService;
private readonly IImportListExclusionService _importListExclusionService;
private readonly Logger _logger;
public RefreshArtistService(IProvideArtistInfo artistInfo,
@ -41,6 +43,7 @@ namespace NzbDrone.Core.Music
IDiskScanService diskScanService,
ICheckIfArtistShouldBeRefreshed checkIfArtistShouldBeRefreshed,
IConfigService configService,
IImportListExclusionService importListExclusionService,
Logger logger)
{
_artistInfo = artistInfo;
@ -53,6 +56,7 @@ namespace NzbDrone.Core.Music
_diskScanService = diskScanService;
_checkIfArtistShouldBeRefreshed = checkIfArtistShouldBeRefreshed;
_configService = configService;
_importListExclusionService = importListExclusionService;
_logger = logger;
}
@ -75,6 +79,16 @@ namespace NzbDrone.Core.Music
if (artist.Metadata.Value.ForeignArtistId != artistInfo.Metadata.Value.ForeignArtistId)
{
_logger.Warn("Artist '{0}' (Artist {1}) was replaced with '{2}' (LidarrAPI {3}), because the original was a duplicate.", artist.Name, artist.Metadata.Value.ForeignArtistId, artistInfo.Name, artistInfo.Metadata.Value.ForeignArtistId);
// Update list exclusion if one exists
var importExclusion = _importListExclusionService.FindByForeignId(artist.Metadata.Value.ForeignArtistId);
if (importExclusion != null)
{
importExclusion.ForeignId = artistInfo.Metadata.Value.ForeignArtistId;
_importListExclusionService.Update(importExclusion);
}
artist.Metadata.Value.ForeignArtistId = artistInfo.Metadata.Value.ForeignArtistId;
}

View File

@ -194,6 +194,7 @@
<Compile Include="Datastore\Migration\017_remove_nma.cs" />
<Compile Include="Datastore\Migration\018_album_disambiguation.cs" />
<Compile Include="Datastore\Migration\019_add_ape_quality_in_profiles.cs" />
<Compile Include="Datastore\Migration\027_add_import_exclusions.cs" />
<Compile Include="Datastore\Migration\021_add_custom_filters.cs" />
<Compile Include="Datastore\Migration\022_import_list_tags.cs" />
<Compile Include="Datastore\Migration\023_add_release_groups_etc.cs" />
@ -545,6 +546,10 @@
<Compile Include="Http\HttpProxySettingsProvider.cs" />
<Compile Include="Http\TorcacheHttpInterceptor.cs" />
<Compile Include="ImportLists\Exceptions\ImportListException.cs" />
<Compile Include="ImportLists\Exclusions\ImportListExclusionExistsValidator.cs" />
<Compile Include="ImportLists\Exclusions\ImportListExclusionService.cs" />
<Compile Include="ImportLists\Exclusions\ImportListExclusionRepository.cs" />
<Compile Include="ImportLists\Exclusions\ImportListExclusion.cs" />
<Compile Include="ImportLists\FetchAndParseImportListService.cs" />
<Compile Include="ImportLists\HeadphonesImport\HeadphonesImport.cs" />
<Compile Include="ImportLists\HeadphonesImport\HeadphonesImportApi.cs" />
@ -1200,6 +1205,7 @@
<Compile Include="Update\UpdateVerification.cs" />
<Compile Include="Update\UpdateVerificationFailedException.cs" />
<Compile Include="Validation\FolderValidator.cs" />
<Compile Include="Validation\GuidValidator.cs" />
<Compile Include="Validation\IpValidation.cs" />
<Compile Include="Validation\LanguageProfileExistsValidator.cs" />
<Compile Include="Validation\MetadataProfileExistsValidator.cs" />

View File

@ -0,0 +1,20 @@
using System;
using FluentValidation.Validators;
namespace NzbDrone.Core.Validation
{
public class GuidValidator : PropertyValidator
{
public GuidValidator()
: base("String is not a valid Guid")
{
}
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null) return false;
return Guid.TryParse(context.PropertyValue.ToString(), out Guid guidOutput);
}
}
}