New: Add/remove individual albums

This commit is contained in:
ta264 2019-12-16 21:21:32 +00:00 committed by Qstick
parent 8da53ae6aa
commit 0bde5fd9e5
128 changed files with 2796 additions and 743 deletions

View File

@ -1,241 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import { icons, kinds, inputTypes, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
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 CheckInput from 'Components/Form/CheckInput';
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 Popover from 'Components/Tooltip/Popover';
import ArtistPoster from 'Artist/ArtistPoster';
import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
import styles from './AddNewArtistModalContent.css';
class AddNewArtistModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
searchForMissingAlbums: false
};
}
//
// Listeners
onSearchForMissingAlbumsChange = ({ value }) => {
this.setState({ searchForMissingAlbums: value });
}
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
}
onMetadataProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) });
}
onAddArtistPress = () => {
this.props.onAddArtistPress(this.state.searchForMissingAlbums);
}
//
// Render
render() {
const {
artistName,
overview,
images,
isAdding,
rootFolderPath,
monitor,
qualityProfileId,
metadataProfileId,
albumFolder,
tags,
showMetadataProfile,
isSmallScreen,
onModalClose,
onInputChange,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{artistName}
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
isSmallScreen ?
null:
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
{
overview ?
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div> :
null
}
<Form {...otherProps}>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup className={showMetadataProfile ? undefined : styles.hideMetadataProfile}>
<FormLabel>Metadata Profile</FormLabel>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
onChange={this.onMetadataProfileIdChange}
{...metadataProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
onChange={onInputChange}
{...albumFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingAlbumsLabelContainer}>
<span className={styles.searchForMissingAlbumsLabel}>
Start search for missing albums
</span>
<CheckInput
containerClassName={styles.searchForMissingAlbumsContainer}
className={styles.searchForMissingAlbumsInput}
name="searchForMissingAlbums"
value={this.state.searchForMissingAlbums}
onChange={this.onSearchForMissingAlbumsChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddArtistPress}
>
Add {artistName}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewArtistModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
metadataProfileId: PropTypes.object,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onAddArtistPress: PropTypes.func.isRequired
};
export default AddNewArtistModalContent;

View File

@ -0,0 +1,11 @@
import React from 'react';
function ArtistMetadataProfilePopoverContent() {
return (
<div>
Select 'None' to only include items manually added via search
</div>
);
}
export default ArtistMetadataProfilePopoverContent;

View File

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { setAddArtistDefault } from 'Store/Actions/addArtistActions';
import { setAddDefault } from 'Store/Actions/searchActions';
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
import ImportArtist from './ImportArtist';
@ -67,7 +67,7 @@ const mapDispatchToProps = {
dispatchImportArtist: importArtist,
dispatchClearImportArtist: clearImportArtist,
dispatchFetchRootFolders: fetchRootFolders,
dispatchSetAddArtistDefault: setAddArtistDefault
dispatchSetAddDefault: setAddDefault
};
class ImportArtistConnector extends Component {
@ -82,7 +82,7 @@ class ImportArtistConnector extends Component {
defaultQualityProfileId,
defaultMetadataProfileId,
dispatchFetchRootFolders,
dispatchSetAddArtistDefault
dispatchSetAddDefault
} = this.props;
if (!this.props.rootFoldersPopulated) {
@ -109,7 +109,7 @@ class ImportArtistConnector extends Component {
}
if (setDefaults) {
dispatchSetAddArtistDefault(setDefaultPayload);
dispatchSetAddDefault(setDefaultPayload);
}
}
@ -121,7 +121,7 @@ class ImportArtistConnector extends Component {
// Listeners
onInputChange = (ids, name, value) => {
this.props.dispatchSetAddArtistDefault({ [name]: value });
this.props.dispatchSetAddDefault({ [name]: value });
ids.forEach((id) => {
this.props.dispatchSetImportArtistValue({
@ -164,7 +164,7 @@ ImportArtistConnector.propTypes = {
dispatchImportArtist: PropTypes.func.isRequired,
dispatchClearImportArtist: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchSetAddArtistDefault: PropTypes.func.isRequired
dispatchSetAddDefault: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector);

View File

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

View File

@ -0,0 +1,12 @@
.pathContainer {
margin-bottom: 20px;
}
.pathIcon {
margin-right: 8px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}

View File

@ -0,0 +1,157 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import styles from './DeleteAlbumModalContent.css';
class DeleteAlbumModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false,
addImportListExclusion: true
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteAlbumConfirmed = () => {
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
this.setState({ deleteFiles: false });
this.setState({ addImportListExclusion: false });
this.props.onDeletePress(deleteFiles, addImportListExclusion);
}
//
// Render
render() {
const {
title,
statistics,
onModalClose
} = this.props;
const {
trackFileCount,
sizeOnDisk
} = statistics;
const deleteFiles = this.state.deleteFiles;
const addImportListExclusion = this.state.addImportListExclusion;
const deleteFilesLabel = `Delete ${trackFileCount} Track Files`;
const deleteFilesHelpText = 'Delete the track files';
return (
<ModalContent
onModalClose={onModalClose}
>
<ModalHeader>
Delete - {title}
</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{deleteFilesLabel}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={deleteFilesHelpText}
kind={kinds.DANGER}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Add List Exclusion</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText="Prevent album from being added to Lidarr by Import Lists or Artist Refresh"
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
{
!addImportListExclusion &&
<div className={styles.deleteFilesMessage}>
<div>If you don't add an import list exclusion and the artist has a metadata profile other than 'None' then this album may be re-added during the next artist refresh.</div>
</div>
}
{
deleteFiles &&
<div className={styles.deleteFilesMessage}>
<div>The album's files will be deleted.</div>
{
!!trackFileCount &&
<div>{trackFileCount} track files totaling {formatBytes(sizeOnDisk)}</div>
}
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteAlbumConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteAlbumModalContent.propTypes = {
title: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
onDeletePress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
DeleteAlbumModalContent.defaultProps = {
statistics: {
trackFileCount: 0
}
};
export default DeleteAlbumModalContent;

View File

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'connected-react-router';
import createAlbumSelector from 'Store/Selectors/createAlbumSelector';
import { deleteAlbum } from 'Store/Actions/albumActions';
import DeleteAlbumModalContent from './DeleteAlbumModalContent';
function createMapStateToProps() {
return createSelector(
createAlbumSelector(),
(album) => {
return album;
}
);
}
const mapDispatchToProps = {
push,
deleteAlbum
};
class DeleteAlbumModalContentConnector extends Component {
//
// Listeners
onDeletePress = (deleteFiles, addImportListExclusion) => {
this.props.deleteAlbum({
id: this.props.albumId,
deleteFiles,
addImportListExclusion
});
this.props.onModalClose(true);
this.props.push(`${window.Lidarr.urlBase}/artist/${this.props.foreignArtistId}`);
}
//
// Render
render() {
return (
<DeleteAlbumModalContent
{...this.props}
onDeletePress={this.onDeletePress}
/>
);
}
}
DeleteAlbumModalContentConnector.propTypes = {
albumId: PropTypes.number.isRequired,
foreignArtistId: PropTypes.string.isRequired,
push: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
deleteAlbum: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(DeleteAlbumModalContentConnector);

View File

@ -18,6 +18,7 @@ import AlbumCover from 'Album/AlbumCover';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector';
import DeleteAlbumModal from 'Album/Delete/DeleteAlbumModal';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
@ -88,6 +89,7 @@ class AlbumDetails extends Component {
isInteractiveSearchModalOpen: false,
isManageTracksOpen: false,
isEditAlbumModalOpen: false,
isDeleteAlbumModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {}
@ -121,6 +123,17 @@ class AlbumDetails extends Component {
this.setState({ isEditAlbumModalOpen: false });
}
onDeleteAlbumPress = () => {
this.setState({
isEditAlbumModalOpen: false,
isDeleteAlbumModalOpen: true
});
}
onDeleteAlbumModalClose = () => {
this.setState({ isDeleteAlbumModalOpen: false });
}
onManageTracksPress = () => {
this.setState({ isManageTracksOpen: true });
}
@ -208,6 +221,7 @@ class AlbumDetails extends Component {
isArtistHistoryModalOpen,
isInteractiveSearchModalOpen,
isEditAlbumModalOpen,
isDeleteAlbumModalOpen,
isManageTracksOpen,
allExpanded,
allCollapsed,
@ -276,6 +290,12 @@ class AlbumDetails extends Component {
onPress={this.onEditAlbumPress}
/>
<PageToolbarButton
label="Delete"
iconName={icons.DELETE}
onPress={this.onDeleteAlbumPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
@ -549,6 +569,14 @@ class AlbumDetails extends Component {
albumId={id}
artistId={artist.id}
onModalClose={this.onEditAlbumModalClose}
onDeleteArtistPress={this.onDeleteAlbumPress}
/>
<DeleteAlbumModal
isOpen={isDeleteAlbumModalOpen}
albumId={id}
foreignArtistId={artist.foreignArtistId}
onModalClose={this.onDeleteAlbumModalClose}
/>
</PageContentBodyConnector>

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
@ -20,6 +21,18 @@ function createMapStateToProps() {
const isFetching = albums.isFetching || artist.isFetching;
const isPopulated = albums.isPopulated && artist.isPopulated;
// if albums have been fetched, make sure requested one exists
// otherwise don't map foreignAlbumId to trigger not found page
if (!isFetching && isPopulated) {
const albumIndex = _.findIndex(albums.items, { foreignAlbumId });
if (albumIndex === -1) {
return {
isFetching,
isPopulated
};
}
}
return {
foreignAlbumId,
isFetching,

View File

@ -5,7 +5,7 @@ import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector';
import AddNewItemConnector from 'Search/AddNewItemConnector';
import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
@ -72,8 +72,8 @@ function AppRoutes(props) {
}
<Route
path="/add/new"
component={AddNewArtistConnector}
path="/add/search"
component={AddNewItemConnector}
/>
<Route

View File

@ -611,7 +611,11 @@ class ArtistDetails extends Component {
</div>
<div className={styles.metadataMessage}>
Missing Albums, Singles, or Other Types? Modify or Create a New <Link to='/settings/profiles'> Metadata Profile</Link>!
Missing Albums, Singles, or Other Types? Modify or create a new
<Link to='/settings/profiles'> Metadata Profile </Link>
or manually
<Link to={`/add/search?term=${encodeURIComponent(artistName)}`}> Search </Link>
for new items!
</div>
<OrganizePreviewModalConnector

View File

@ -28,13 +28,15 @@ const selectAlbums = createSelector(
const hasAlbums = !!items.length;
const hasMonitoredAlbums = items.some((e) => e.monitored);
const albumTypes = _.uniq(_.map(items, 'albumType'));
return {
isAlbumsFetching: isFetching,
isAlbumsPopulated: isPopulated,
albumsError: error,
hasAlbums,
hasMonitoredAlbums
hasMonitoredAlbums,
albumTypes
};
}
);
@ -65,20 +67,12 @@ function createMapStateToProps() {
(state, { foreignArtistId }) => foreignArtistId,
selectAlbums,
selectTrackFiles,
(state) => state.settings.metadataProfiles,
createAllArtistSelector(),
createCommandsSelector(),
(foreignArtistId, albums, trackFiles, metadataProfiles, allArtists, commands) => {
(foreignArtistId, albums, trackFiles, allArtists, commands) => {
const sortedArtist = _.orderBy(allArtists, 'sortName');
const artistIndex = _.findIndex(sortedArtist, { foreignArtistId });
const artist = sortedArtist[artistIndex];
const metadataProfile = _.find(metadataProfiles.items, { id: artist.metadataProfileId });
const albumTypes = _.reduce(metadataProfile.primaryAlbumTypes, (acc, primaryType) => {
if (primaryType.allowed) {
acc.push(primaryType.albumType.name);
}
return acc;
}, []);
if (!artist) {
return {};
@ -89,7 +83,8 @@ function createMapStateToProps() {
isAlbumsPopulated,
albumsError,
hasAlbums,
hasMonitoredAlbums
hasMonitoredAlbums,
albumTypes
} = albums;
const {

View File

@ -3,3 +3,7 @@
margin-right: auto;
}
.labelIcon {
margin-left: 8px;
}

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ModalContent from 'Components/Modal/ModalContent';
@ -11,7 +11,10 @@ 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 Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import MoveArtistModal from 'Artist/MoveArtist/MoveArtistModal';
import ArtistMetadataProfilePopoverContent from 'AddArtist/ArtistMetadataProfilePopoverContent';
import styles from './EditArtistModalContent.css';
class EditArtistModalContent extends Component {
@ -122,12 +125,28 @@ class EditArtistModalContent extends Component {
{
showMetadataProfile &&
<FormGroup>
<FormLabel>Metadata Profile</FormLabel>
<FormLabel>
Metadata Profile
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Metadata Profile"
body={<ArtistMetadataProfilePopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
helpText="Changes will take place on next artist refresh"
includeNone={true}
{...metadataProfileId}
onChange={onInputChange}
/>

View File

@ -20,7 +20,7 @@ function NoArtist(props) {
return (
<div>
<div className={styles.message}>
No artist found, to get started you'll want to add a new artist or import some existing ones.
No artist found, to get started you'll want to add a new artist or album or import some existing ones.
</div>
<div className={styles.buttonContainer}>
@ -34,7 +34,7 @@ function NoArtist(props) {
<div className={styles.buttonContainer}>
<Button
to="/add/new"
to="/add/search"
kind={kinds.PRIMARY}
>
Add New Artist

View File

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import sortByName from 'Utilities/Array/sortByName';
import { metadataProfileNames } from 'Helpers/Props';
import SelectInput from './SelectInput';
function createMapStateToProps() {
@ -11,14 +12,26 @@ function createMapStateToProps() {
(state) => state.settings.metadataProfiles,
(state, { includeNoChange }) => includeNoChange,
(state, { includeMixed }) => includeMixed,
(metadataProfiles, includeNoChange, includeMixed) => {
const values = _.map(metadataProfiles.items.sort(sortByName), (metadataProfile) => {
(state, { includeNone }) => includeNone,
(metadataProfiles, includeNoChange, includeMixed, includeNone) => {
const profiles = metadataProfiles.items.filter((item) => item.name !== metadataProfileNames.NONE);
const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE);
const values = _.map(profiles.sort(sortByName), (metadataProfile) => {
return {
key: metadataProfile.id,
value: metadataProfile.name
};
});
if (includeNone) {
values.push({
key: noneProfile.id,
value: noneProfile.name
});
}
if (includeNoChange) {
values.unshift({
key: 'noChange',
@ -88,6 +101,7 @@ MetadataProfileSelectInputConnector.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeNoChange: PropTypes.bool.isRequired,
includeNone: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired
};

View File

@ -209,7 +209,7 @@ class ArtistSearchInput extends Component {
}
suggestionGroups.push({
title: 'Add New Artist',
title: 'Add New Item',
suggestions: [
{
type: ADD_NEW_TYPE,

View File

@ -58,7 +58,7 @@ function createMapDispatchToProps(dispatch, props) {
},
onGoToAddNewArtist(query) {
dispatch(push(`${window.Lidarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
dispatch(push(`${window.Lidarr.urlBase}/add/search?term=${encodeURIComponent(query)}`));
}
};
}

View File

@ -26,7 +26,7 @@ const links = [
children: [
{
title: 'Add New',
to: '/add/new'
to: '/add/search'
},
{
title: 'Import',

View File

@ -176,12 +176,20 @@ class SignalRConnector extends Component {
}
handleAlbum = (body) => {
if (body.action === 'updated') {
const action = body.action;
const section = 'albums';
if (action === 'updated') {
this.props.dispatchUpdateItem({
section: 'albums',
section,
updateOnly: true,
...body.resource
});
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({
section,
id: body.resource.id
});
}
}

View File

@ -7,6 +7,7 @@ import * as filterTypes from './filterTypes';
import * as icons from './icons';
import * as kinds from './kinds';
import * as messageTypes from './messageTypes';
import * as metadataProfileNames from './metadataProfileNames';
import * as sizes from './sizes';
import * as scrollDirections from './scrollDirections';
import * as sortDirections from './sortDirections';
@ -22,6 +23,7 @@ export {
icons,
kinds,
messageTypes,
metadataProfileNames,
sizes,
scrollDirections,
sortDirections,

View File

@ -0,0 +1 @@
export const NONE = 'None';

View File

@ -8,10 +8,11 @@ import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import AddNewArtistSearchResultConnector from './AddNewArtistSearchResultConnector';
import styles from './AddNewArtist.css';
import AddNewArtistSearchResultConnector from './Artist/AddNewArtistSearchResultConnector';
import AddNewAlbumSearchResultConnector from './Album/AddNewAlbumSearchResultConnector';
import styles from './AddNewItem.css';
class AddNewArtist extends Component {
class AddNewItem extends Component {
//
// Lifecycle
@ -29,7 +30,7 @@ class AddNewArtist extends Component {
const term = this.state.term;
if (term) {
this.props.onArtistLookupChange(term);
this.props.onSearchChange(term);
}
}
@ -44,7 +45,7 @@ class AddNewArtist extends Component {
term,
isFetching: true
});
this.props.onArtistLookupChange(term);
this.props.onSearchChange(term);
} else if (isFetching !== prevProps.isFetching) {
this.setState({
isFetching
@ -60,16 +61,16 @@ class AddNewArtist extends Component {
this.setState({ term: value, isFetching: hasValue }, () => {
if (hasValue) {
this.props.onArtistLookupChange(value);
this.props.onSearchChange(value);
} else {
this.props.onClearArtistLookup();
this.props.onClearSearch();
}
});
}
onClearArtistLookupPress = () => {
onClearSearchPress = () => {
this.setState({ term: '' });
this.props.onClearArtistLookup();
this.props.onClearSearch();
}
//
@ -85,7 +86,7 @@ class AddNewArtist extends Component {
const isFetching = this.state.isFetching;
return (
<PageContent title="Add New Artist">
<PageContent title="Add New Item">
<PageContentBodyConnector>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
@ -97,7 +98,7 @@ class AddNewArtist extends Component {
<TextInput
className={styles.searchInput}
name="artistLookup"
name="searchBox"
value={term}
placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25"
autoFocus={true}
@ -106,7 +107,7 @@ class AddNewArtist extends Component {
<Button
className={styles.clearLookupButton}
onPress={this.onClearArtistLookupPress}
onPress={this.onClearSearchPress}
>
<Icon
name={icons.REMOVE}
@ -130,12 +131,26 @@ class AddNewArtist extends Component {
<div className={styles.searchResults}>
{
items.map((item) => {
return (
<AddNewArtistSearchResultConnector
key={item.foreignArtistId}
{...item}
/>
);
if (item.artist) {
const artist = item.artist;
return (
<AddNewArtistSearchResultConnector
key={item.id}
{...artist}
/>
);
} else if (item.album) {
const album = item.album;
return (
<AddNewAlbumSearchResultConnector
key={item.id}
isExistingAlbum={'id' in album && album.id !== 0}
isExistingArtist={'id' in album.artist && album.artist.id !== 0}
{...album}
/>
);
}
return null;
})
}
</div>
@ -145,11 +160,10 @@ class AddNewArtist extends Component {
!isFetching && !error && !items.length && !!term &&
<div className={styles.message}>
<div className={styles.noResults}>Couldn't find any results for '{term}'</div>
<div>You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234</div>
<div>
<Link to="https://github.com/Lidarr/Lidarr/wiki/FAQ#why-cant-i-add-a-new-artist-when-i-know-the-musicbrainz-id">
Why can't I find my artist?
</Link>
You can also search using the
<Link to="https://musicbrainz.org/search"> MusicBrainz ID </Link>
of an artist e.g. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
</div>
</div>
}
@ -157,8 +171,12 @@ class AddNewArtist extends Component {
{
!term &&
<div className={styles.message}>
<div className={styles.helpText}>It's easy to add a new artist, just start typing the name the artist you want to add.</div>
<div>You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234</div>
<div className={styles.helpText}>It's easy to add a new artist, just start typing the name of the artist you want to add.</div>
<div>
You can also search using the
<Link to="https://musicbrainz.org/search"> MusicBrainz ID </Link>
of an artist e.g. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
</div>
</div>
}
@ -169,15 +187,15 @@ class AddNewArtist extends Component {
}
}
AddNewArtist.propTypes = {
AddNewItem.propTypes = {
term: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onArtistLookupChange: PropTypes.func.isRequired,
onClearArtistLookup: PropTypes.func.isRequired
onSearchChange: PropTypes.func.isRequired,
onClearSearch: PropTypes.func.isRequired
};
export default AddNewArtist;
export default AddNewItem;

View File

@ -3,32 +3,32 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import parseUrl from 'Utilities/String/parseUrl';
import { lookupArtist, clearAddArtist } from 'Store/Actions/addArtistActions';
import { getSearchResults, clearSearchResults } from 'Store/Actions/searchActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import AddNewArtist from './AddNewArtist';
import AddNewItem from './AddNewItem';
function createMapStateToProps() {
return createSelector(
(state) => state.addArtist,
(state) => state.search,
(state) => state.router.location,
(addArtist, location) => {
(search, location) => {
const { params } = parseUrl(location.search);
return {
term: params.term,
...addArtist
...search
};
}
);
}
const mapDispatchToProps = {
lookupArtist,
clearAddArtist,
getSearchResults,
clearSearchResults,
fetchRootFolders
};
class AddNewArtistConnector extends Component {
class AddNewItemConnector extends Component {
//
// Lifecycle
@ -36,7 +36,7 @@ class AddNewArtistConnector extends Component {
constructor(props, context) {
super(props, context);
this._artistLookupTimeout = null;
this._searchTimeout = null;
}
componentDidMount() {
@ -44,32 +44,32 @@ class AddNewArtistConnector extends Component {
}
componentWillUnmount() {
if (this._artistLookupTimeout) {
clearTimeout(this._artistLookupTimeout);
if (this._searchTimeout) {
clearTimeout(this._searchTimeout);
}
this.props.clearAddArtist();
this.props.clearSearchResults();
}
//
// Listeners
onArtistLookupChange = (term) => {
if (this._artistLookupTimeout) {
clearTimeout(this._artistLookupTimeout);
onSearchChange = (term) => {
if (this._searchTimeout) {
clearTimeout(this._searchTimeout);
}
if (term.trim() === '') {
this.props.clearAddArtist();
this.props.clearSearchResults();
} else {
this._artistLookupTimeout = setTimeout(() => {
this.props.lookupArtist({ term });
this._searchTimeout = setTimeout(() => {
this.props.getSearchResults({ term });
}, 300);
}
}
onClearArtistLookup = () => {
this.props.clearAddArtist();
onClearSearch = () => {
this.props.clearSearchResults();
}
//
@ -82,21 +82,21 @@ class AddNewArtistConnector extends Component {
} = this.props;
return (
<AddNewArtist
<AddNewItem
term={term}
{...otherProps}
onArtistLookupChange={this.onArtistLookupChange}
onClearArtistLookup={this.onClearArtistLookup}
onSearchChange={this.onSearchChange}
onClearSearch={this.onClearSearch}
/>
);
}
}
AddNewArtistConnector.propTypes = {
AddNewItemConnector.propTypes = {
term: PropTypes.string,
lookupArtist: PropTypes.func.isRequired,
clearAddArtist: PropTypes.func.isRequired,
getSearchResults: PropTypes.func.isRequired,
clearSearchResults: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewArtistConnector);
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewItemConnector);

View File

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

View File

@ -0,0 +1,126 @@
.container {
display: flex;
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.info {
flex-grow: 1;
}
.name {
font-weight: 300;
font-size: 36px;
}
.artistName {
margin-bottom: 20px;
font-weight: 300;
font-size: 20px;
}
.disambiguation {
margin-bottom: 20px;
color: $disabledColor;
font-weight: 300;
font-size: 20px;
}
.overview {
margin-bottom: 30px;
max-height: 230px;
text-align: justify;
}
.header {
position: relative;
display: flex;
align-items: center;
margin-top: 5px;
margin-bottom: 5px;
width: 100%;
font-size: 24px;
cursor: pointer;
}
.left {
display: flex;
align-items: center;
flex: 0 1 300px;
}
.albumType {
margin-bottom: 20px;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
&:last-of-type {
margin-bottom: 0;
}
}
.albumTypeLabel {
margin-right: 5px;
margin-left: 5px;
}
.albumCount {
color: #8895aa;
font-style: italic;
font-size: 18px;
}
.expandButton {
composes: link from '~Components/Link/Link.css';
flex-grow: 1;
width: 100%;
text-align: center;
}
.searchForNewAlbumLabelContainer {
display: flex;
margin-top: 2px;
}
.searchForNewAlbumLabel {
margin-right: 8px;
font-weight: normal;
}
.searchForNewAlbumContainer {
composes: container from '~Components/Form/CheckInput.css';
flex: 0 1 0;
}
.searchForNewAlbumInput {
composes: input from '~Components/Form/CheckInput.css';
margin-top: 0;
}
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
}
.addButton {
@add-mixin truncate;
composes: button from '~Components/Link/SpinnerButton.css';
}
@media only screen and (max-width: $breakpointSmall) {
.modalFooter {
display: block;
text-align: center;
}
.addButton {
margin-top: 10px;
}
}

View File

@ -0,0 +1,157 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import { kinds } from 'Helpers/Props';
import SpinnerButton from 'Components/Link/SpinnerButton';
import CheckInput from 'Components/Form/CheckInput';
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 AlbumCover from 'Album/AlbumCover';
import AddArtistOptionsForm from '../Common/AddArtistOptionsForm.js';
import styles from './AddNewAlbumModalContent.css';
class AddNewAlbumModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
searchForNewAlbum: false
};
}
//
// Listeners
onSearchForNewAlbumChange = ({ value }) => {
this.setState({ searchForNewAlbum: value });
}
onAddAlbumPress = () => {
this.props.onAddAlbumPress(this.state.searchForNewAlbum);
}
//
// Render
render() {
const {
albumTitle,
artistName,
disambiguation,
overview,
images,
isAdding,
isExistingArtist,
isSmallScreen,
onModalClose,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add new Album
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
isSmallScreen ?
null:
<div className={styles.poster}>
<AlbumCover
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.name}>
{albumTitle}
</div>
{
!!disambiguation &&
<span className={styles.disambiguation}>({disambiguation})</span>
}
<div>
<span className={styles.artistName}> By: {artistName}</span>
</div>
{
overview ?
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div> :
null
}
{
!isExistingArtist &&
<AddArtistOptionsForm
artistName={artistName}
includeNoneMetadataProfile={true}
{...otherProps}
/>
}
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForNewAlbumLabelContainer}>
<span className={styles.searchForNewAlbumLabel}>
Start search for new album
</span>
<CheckInput
containerClassName={styles.searchForNewAlbumContainer}
className={styles.searchForNewAlbumInput}
name="searchForNewAlbum"
value={this.state.searchForNewAlbum}
onChange={this.onSearchForNewAlbumChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddAlbumPress}
>
Add {albumTitle}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewAlbumModalContent.propTypes = {
albumTitle: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
isExistingArtist: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onAddAlbumPress: PropTypes.func.isRequired
};
export default AddNewAlbumModalContent;

View File

@ -0,0 +1,135 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { metadataProfileNames } from 'Helpers/Props';
import { setAddDefault, addAlbum } from 'Store/Actions/searchActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewAlbumModalContent from './AddNewAlbumModalContent';
function createMapStateToProps() {
return createSelector(
(state, { isExistingArtist }) => isExistingArtist,
(state) => state.search,
(state) => state.settings.metadataProfiles,
createDimensionsSelector(),
(isExistingArtist, searchState, metadataProfiles, dimensions) => {
const {
isAdding,
addError,
defaults
} = searchState;
const {
settings,
validationErrors,
validationWarnings
} = selectSettings(defaults, {}, addError);
// For adding single albums, default to None profile
const noneProfile = metadataProfiles.items.find((item) => item.name === metadataProfileNames.NONE);
return {
isAdding,
addError,
showMetadataProfile: true,
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
noneMetadataProfileId: noneProfile.id,
...settings
};
}
);
}
const mapDispatchToProps = {
setAddDefault,
addAlbum
};
class AddNewAlbumModalContentConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
metadataProfileIdDefault: props.metadataProfileId.value
};
// select none as default
this.onInputChange({
name: 'metadataProfileId',
value: props.noneMetadataProfileId
});
}
componentWillUnmount() {
// reinstate standard default
this.props.setAddDefault({ metadataProfileId: this.state.metadataProfileIdDefault });
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddDefault({ [name]: value });
}
onAddAlbumPress = (searchForNewAlbum) => {
const {
foreignAlbumId,
rootFolderPath,
monitor,
qualityProfileId,
metadataProfileId,
albumFolder,
tags
} = this.props;
this.props.addAlbum({
foreignAlbumId,
rootFolderPath: rootFolderPath.value,
monitor: monitor.value,
qualityProfileId: qualityProfileId.value,
metadataProfileId: metadataProfileId.value,
albumFolder: albumFolder.value,
tags: tags.value,
searchForNewAlbum
});
}
//
// Render
render() {
return (
<AddNewAlbumModalContent
{...this.props}
onInputChange={this.onInputChange}
onAddAlbumPress={this.onAddAlbumPress}
/>
);
}
}
AddNewAlbumModalContentConnector.propTypes = {
isExistingArtist: PropTypes.bool.isRequired,
foreignAlbumId: PropTypes.string.isRequired,
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
metadataProfileId: PropTypes.object,
noneMetadataProfileId: PropTypes.number.isRequired,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddDefault: PropTypes.func.isRequired,
addAlbum: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AddNewAlbumModalContentConnector);

View File

@ -0,0 +1,64 @@
.searchResult {
display: flex;
margin: 20px 0;
padding: 20px;
width: 100%;
background-color: $white;
color: inherit;
transition: background 500ms;
&:hover {
background-color: #eaf2ff;
color: inherit;
text-decoration: none;
}
}
.poster {
flex: 0 0 170px;
margin-right: 20px;
height: 250px;
}
.content {
flex: 0 1 100%;
}
.name {
display: flex;
font-weight: 300;
font-size: 36px;
}
.artistName {
font-weight: 300;
font-size: 20px;
}
.year {
margin-left: 10px;
color: $disabledColor;
}
.mbLink {
composes: link from '~Components/Link/Link.css';
margin-top: -4px;
margin-left: auto;
color: $textColor;
}
.mbLinkIcon {
margin-left: 10px;
}
.alreadyExistsIcon {
margin-left: 10px;
color: #37bc9b;
}
.overview {
overflow: hidden;
margin-top: 20px;
text-align: justify;
}

View File

@ -0,0 +1,250 @@
import moment from 'moment';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import { icons, sizes } from 'Helpers/Props';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import AlbumCover from 'Album/AlbumCover';
import AddNewAlbumModal from './AddNewAlbumModal';
import styles from './AddNewAlbumSearchResult.css';
const columnPadding = parseInt(dimensions.artistIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen);
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function calculateHeight(rowHeight, isSmallScreen) {
let height = rowHeight - 70;
if (isSmallScreen) {
height -= columnPaddingSmallScreen;
} else {
height -= columnPadding;
}
return height;
}
class AddNewAlbumSearchResult extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNewAddAlbumModalOpen: false
};
}
componentDidUpdate(prevProps) {
if (!prevProps.isExistingAlbum && this.props.isExistingAlbum) {
this.onAddAlbumModalClose();
}
}
//
// Listeners
onPress = () => {
this.setState({ isNewAddAlbumModalOpen: true });
}
onAddAlbumModalClose = () => {
this.setState({ isNewAddAlbumModalOpen: false });
}
onMBLinkPress = (event) => {
event.stopPropagation();
}
//
// Render
render() {
const {
foreignAlbumId,
title,
releaseDate,
disambiguation,
albumType,
secondaryTypes,
overview,
ratings,
images,
releases,
artist,
isExistingAlbum,
isExistingArtist,
isSmallScreen
} = this.props;
const {
isNewAddAlbumModalOpen
} = this.state;
const linkProps = isExistingAlbum ? { to: `/album/${foreignAlbumId}` } : { onPress: this.onPress };
const height = calculateHeight(230, isSmallScreen);
return (
<div>
<Link
className={styles.searchResult}
{...linkProps}
>
{
!isSmallScreen &&
<AlbumCover
className={styles.poster}
images={images}
size={250}
/>
}
<div className={styles.content}>
<div className={styles.name}>
{title}
{
!!disambiguation &&
<span className={styles.year}>({disambiguation})</span>
}
{
isExistingAlbum ?
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={20}
title="Album already in your library"
/> :
null
}
<Link
className={styles.mbLink}
to={`https://musicbrainz.org/release-group/${foreignAlbumId}`}
onPress={this.onMBLinkPress}
>
<Icon
className={styles.mbLinkIcon}
name={icons.EXTERNAL_LINK}
size={28}
/>
</Link>
</div>
<div>
<span className={styles.artistName}> By: {artist.artistName}</span>
{
isExistingArtist ?
<Icon
className={styles.alreadyExistsIcon}
name={icons.CHECK_CIRCLE}
size={15}
title="Artist already in your library"
/> :
null
}
</div>
<div>
<Label size={sizes.LARGE}>
<HeartRating
rating={ratings.value}
iconSize={13}
/>
</Label>
{
!!releaseDate &&
<Label size={sizes.LARGE}>
{moment(releaseDate).format('YYYY')}
</Label>
}
<Label size={sizes.LARGE}>
{releases.length} release{releases.length > 0 ? 's' : null}
</Label>
{
!!albumType &&
<Label size={sizes.LARGE}>
{albumType}
</Label>
}
{
!!secondaryTypes &&
secondaryTypes.map((item, i) => {
return (
<Label
size={sizes.LARGE}
key={i}
>
{item}
</Label>
);
})
}
</div>
<div
className={styles.overview}
style={{
maxHeight: `${height}px`
}}
>
<TextTruncate
truncateText="…"
line={Math.floor(height / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
</div>
</Link>
<AddNewAlbumModal
isOpen={isNewAddAlbumModalOpen && !isExistingAlbum}
isExistingArtist={isExistingArtist}
foreignAlbumId={foreignAlbumId}
albumTitle={title}
disambiguation={disambiguation}
artistName={artist.artistName}
overview={overview}
images={images}
onModalClose={this.onAddAlbumModalClose}
/>
</div>
);
}
}
AddNewAlbumSearchResult.propTypes = {
foreignAlbumId: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
releaseDate: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
albumType: PropTypes.string,
secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
overview: PropTypes.string,
ratings: PropTypes.object.isRequired,
artist: PropTypes.object,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
releases: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingAlbum: PropTypes.bool.isRequired,
isExistingArtist: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired
};
export default AddNewAlbumSearchResult;

View File

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import AddNewAlbumSearchResult from './AddNewAlbumSearchResult';
function createMapStateToProps() {
return createSelector(
createDimensionsSelector(),
(dimensions) => {
return {
isSmallScreen: dimensions.isSmallScreen
};
}
);
}
export default connect(createMapStateToProps)(AddNewAlbumSearchResult);

View File

@ -17,16 +17,24 @@
flex-grow: 1;
}
.name {
font-weight: 300;
font-size: 36px;
}
.disambiguation {
margin-bottom: 20px;
color: $disabledColor;
font-weight: 300;
font-size: 20px;
}
.overview {
margin-bottom: 30px;
max-height: 230px;
text-align: justify;
}
.labelIcon {
margin-left: 8px;
}
.searchForMissingAlbumsLabelContainer {
display: flex;
margin-top: 2px;
@ -58,12 +66,6 @@
composes: button from '~Components/Link/SpinnerButton.css';
}
.hideMetadataProfile {
composes: group from '~Components/Form/FormGroup.css';
display: none;
}
@media only screen and (max-width: $breakpointSmall) {
.modalFooter {
display: block;

View File

@ -0,0 +1,146 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import { kinds } from 'Helpers/Props';
import SpinnerButton from 'Components/Link/SpinnerButton';
import CheckInput from 'Components/Form/CheckInput';
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 ArtistPoster from 'Artist/ArtistPoster';
import AddArtistOptionsForm from '../Common/AddArtistOptionsForm.js';
import styles from './AddNewArtistModalContent.css';
class AddNewArtistModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
searchForMissingAlbums: false
};
}
//
// Listeners
onSearchForMissingAlbumsChange = ({ value }) => {
this.setState({ searchForMissingAlbums: value });
}
onAddArtistPress = () => {
this.props.onAddArtistPress(this.state.searchForMissingAlbums);
}
//
// Render
render() {
const {
artistName,
disambiguation,
overview,
images,
isAdding,
isSmallScreen,
onModalClose,
...otherProps
} = this.props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add new Artist
</ModalHeader>
<ModalBody>
<div className={styles.container}>
{
isSmallScreen ?
null:
<div className={styles.poster}>
<ArtistPoster
className={styles.poster}
images={images}
size={250}
/>
</div>
}
<div className={styles.info}>
<div className={styles.name}>
{artistName}
</div>
{
!!disambiguation &&
<span className={styles.disambiguation}>({disambiguation})</span>
}
{
overview ?
<div className={styles.overview}>
<TextTruncate
truncateText="…"
line={8}
text={overview}
/>
</div> :
null
}
<AddArtistOptionsForm
includeNoneMetadataProfile={false}
{...otherProps}
/>
</div>
</div>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<label className={styles.searchForMissingAlbumsLabelContainer}>
<span className={styles.searchForMissingAlbumsLabel}>
Start search for missing albums
</span>
<CheckInput
containerClassName={styles.searchForMissingAlbumsContainer}
className={styles.searchForMissingAlbumsInput}
name="searchForMissingAlbums"
value={this.state.searchForMissingAlbums}
onChange={this.onSearchForMissingAlbumsChange}
/>
</label>
<SpinnerButton
className={styles.addButton}
kind={kinds.SUCCESS}
isSpinning={isAdding}
onPress={this.onAddArtistPress}
>
Add {artistName}
</SpinnerButton>
</ModalFooter>
</ModalContent>
);
}
}
AddNewArtistModalContent.propTypes = {
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string.isRequired,
overview: PropTypes.string,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
isSmallScreen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onAddArtistPress: PropTypes.func.isRequired
};
export default AddNewArtistModalContent;

View File

@ -2,22 +2,22 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setAddArtistDefault, addArtist } from 'Store/Actions/addArtistActions';
import { setAddDefault, addArtist } from 'Store/Actions/searchActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewArtistModalContent from './AddNewArtistModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.addArtist,
(state) => state.search,
(state) => state.settings.metadataProfiles,
createDimensionsSelector(),
(addArtistState, metadataProfiles, dimensions) => {
(searchState, metadataProfiles, dimensions) => {
const {
isAdding,
addError,
defaults
} = addArtistState;
} = searchState;
const {
settings,
@ -28,7 +28,7 @@ function createMapStateToProps() {
return {
isAdding,
addError,
showMetadataProfile: metadataProfiles.items.length > 1,
showMetadataProfile: metadataProfiles.items.length > 2, // NONE (not allowed for artists) and one other
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
@ -39,7 +39,7 @@ function createMapStateToProps() {
}
const mapDispatchToProps = {
setAddArtistDefault,
setAddDefault,
addArtist
};
@ -49,7 +49,7 @@ class AddNewArtistModalContentConnector extends Component {
// Listeners
onInputChange = ({ name, value }) => {
this.props.setAddArtistDefault({ [name]: value });
this.props.setAddDefault({ [name]: value });
}
onAddArtistPress = (searchForMissingAlbums) => {
@ -98,7 +98,7 @@ AddNewArtistModalContentConnector.propTypes = {
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onModalClose: PropTypes.func.isRequired,
setAddArtistDefault: PropTypes.func.isRequired,
setAddDefault: PropTypes.func.isRequired,
addArtist: PropTypes.func.isRequired
};

View File

@ -196,6 +196,7 @@ class AddNewArtistSearchResult extends Component {
isOpen={isNewAddArtistModalOpen && !isExistingArtist}
foreignArtistId={foreignArtistId}
artistName={artistName}
disambiguation={disambiguation}
year={year}
overview={overview}
images={images}

View File

@ -0,0 +1,9 @@
.labelIcon {
margin-left: 8px;
}
.hideMetadataProfile {
composes: group from '~Components/Form/FormGroup.css';
display: none;
}

View File

@ -0,0 +1,160 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, inputTypes, tooltipPositions } from 'Helpers/Props';
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 Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
import ArtistMetadataProfilePopoverContent from 'AddArtist/ArtistMetadataProfilePopoverContent';
import styles from './AddArtistOptionsForm.css';
class AddArtistOptionsForm extends Component {
//
// Listeners
onQualityProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'qualityProfileId', value: parseInt(value) });
}
onMetadataProfileIdChange = ({ value }) => {
this.props.onInputChange({ name: 'metadataProfileId', value: parseInt(value) });
}
//
// Render
render() {
const {
rootFolderPath,
monitor,
qualityProfileId,
metadataProfileId,
includeNoneMetadataProfile,
showMetadataProfile,
albumFolder,
tags,
onInputChange,
...otherProps
} = this.props;
return (
<Form {...otherProps}>
<FormGroup>
<FormLabel>Root Folder</FormLabel>
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
onChange={onInputChange}
{...rootFolderPath}
/>
</FormGroup>
<FormGroup>
<FormLabel>
Monitor
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</FormLabel>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
onChange={onInputChange}
{...monitor}
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
onChange={this.onQualityProfileIdChange}
{...qualityProfileId}
/>
</FormGroup>
<FormGroup className={showMetadataProfile ? undefined : styles.hideMetadataProfile}>
<FormLabel>
Metadata Profile
{
includeNoneMetadataProfile &&
<Popover
anchor={
<Icon
className={styles.labelIcon}
name={icons.INFO}
/>
}
title="Metadata Profile"
body={<ArtistMetadataProfilePopoverContent />}
position={tooltipPositions.RIGHT}
/>
}
</FormLabel>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
includeNone={includeNoneMetadataProfile}
onChange={this.onMetadataProfileIdChange}
{...metadataProfileId}
/>
</FormGroup>
<FormGroup>
<FormLabel>Album Folder</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
onChange={onInputChange}
{...albumFolder}
/>
</FormGroup>
<FormGroup>
<FormLabel>Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={onInputChange}
{...tags}
/>
</FormGroup>
</Form>
);
}
}
AddArtistOptionsForm.propTypes = {
rootFolderPath: PropTypes.object,
monitor: PropTypes.object.isRequired,
qualityProfileId: PropTypes.object,
metadataProfileId: PropTypes.object,
showMetadataProfile: PropTypes.bool.isRequired,
includeNoneMetadataProfile: PropTypes.bool.isRequired,
albumFolder: PropTypes.object.isRequired,
tags: PropTypes.object.isRequired,
onInputChange: PropTypes.func.isRequired
};
export default AddArtistOptionsForm;

View File

@ -58,12 +58,12 @@ function EditImportListExclusionModalContent(props) {
{...otherProps}
>
<FormGroup>
<FormLabel>Artist Name</FormLabel>
<FormLabel>Entity Name</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="artistName"
helpText="The name of the artist to exclude (can be anything meaningful)"
helpText="The name of the artist/album to exclude (can be anything meaningful)"
{...artistName}
onChange={onInputChange}
/>
@ -75,7 +75,7 @@ function EditImportListExclusionModalContent(props) {
<FormInputGroup
type={inputTypes.TEXT}
name="foreignId"
helpText="The Musicbrainz Id of the artist to exclude"
helpText="The Musicbrainz Id of the artist/album to exclude"
{...foreignId}
onChange={onInputChange}
/>

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByName from 'Utilities/Array/sortByName';
import { icons } from 'Helpers/Props';
import { icons, metadataProfileNames } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
@ -58,7 +58,7 @@ class MetadataProfiles extends Component {
>
<div className={styles.metadataProfiles}>
{
items.sort(sortByName).map((item) => {
items.filter((item) => item.name !== metadataProfileNames.NONE).sort(sortByName).map((item) => {
return (
<MetadataProfile
key={item.id}

View File

@ -10,6 +10,7 @@ import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptio
import createSaveProviderHandler from './Creators/createSaveProviderHandler';
import albumEntities from 'Album/albumEntities';
import createFetchHandler from './Creators/createFetchHandler';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import createHandleActions from './Creators/createHandleActions';
import { updateItem } from './baseActions';
@ -113,6 +114,7 @@ export const SET_ALBUMS_TABLE_OPTION = 'albums/setAlbumsTableOption';
export const CLEAR_ALBUMS = 'albums/clearAlbums';
export const SET_ALBUM_VALUE = 'albums/setAlbumValue';
export const SAVE_ALBUM = 'albums/saveAlbum';
export const DELETE_ALBUM = 'albums/deleteAlbum';
export const TOGGLE_ALBUM_MONITORED = 'albums/toggleAlbumMonitored';
export const TOGGLE_ALBUMS_MONITORED = 'albums/toggleAlbumsMonitored';
@ -128,6 +130,16 @@ export const toggleAlbumsMonitored = createThunk(TOGGLE_ALBUMS_MONITORED);
export const saveAlbum = createThunk(SAVE_ALBUM);
export const deleteAlbum = createThunk(DELETE_ALBUM, (payload) => {
return {
...payload,
queryParams: {
deleteFiles: payload.deleteFiles,
addImportListExclusion: payload.addImportListExclusion
}
};
});
export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => {
return {
section: 'albums',
@ -141,6 +153,7 @@ export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => {
export const actionHandlers = handleThunks({
[FETCH_ALBUMS]: createFetchHandler(section, '/album'),
[SAVE_ALBUM]: createSaveProviderHandler(section, '/album'),
[DELETE_ALBUM]: createRemoveItemHandler(section, '/album'),
[TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) {
const {

View File

@ -1,4 +1,3 @@
import * as addArtist from './addArtistActions';
import * as app from './appActions';
import * as blacklist from './blacklistActions';
import * as calendar from './calendarActions';
@ -24,6 +23,7 @@ import * as artist from './artistActions';
import * as artistEditor from './artistEditorActions';
import * as artistHistory from './artistHistoryActions';
import * as artistIndex from './artistIndexActions';
import * as search from './searchActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
import * as tags from './tagActions';
@ -31,7 +31,6 @@ import * as tracks from './trackActions';
import * as wanted from './wantedActions';
export default [
addArtist,
app,
blacklist,
captcha,
@ -57,6 +56,7 @@ export default [
artistEditor,
artistHistory,
artistIndex,
search,
settings,
system,
tags,

View File

@ -6,15 +6,15 @@ import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getNewArtist from 'Utilities/Artist/getNewArtist';
import getNewAlbum from 'Utilities/Album/getNewAlbum';
import { createThunk, handleThunks } from 'Store/thunks';
import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer';
import createHandleActions from './Creators/createHandleActions';
import { set, update, updateItem } from './baseActions';
//
// Variables
export const section = 'addArtist';
export const section = 'search';
let abortCurrentRequest = null;
//
@ -40,39 +40,33 @@ export const defaultState = {
};
export const persistState = [
'addArtist.defaults'
'search.defaults'
];
//
// Actions Types
export const LOOKUP_ARTIST = 'addArtist/lookupArtist';
export const ADD_ARTIST = 'addArtist/addArtist';
export const SET_ADD_ARTIST_VALUE = 'addArtist/setAddArtistValue';
export const CLEAR_ADD_ARTIST = 'addArtist/clearAddArtist';
export const SET_ADD_ARTIST_DEFAULT = 'addArtist/setAddArtistDefault';
export const GET_SEARCH_RESULTS = 'search/getSearchResults';
export const ADD_ARTIST = 'search/addArtist';
export const ADD_ALBUM = 'search/addAlbum';
export const CLEAR_SEARCH_RESULTS = 'search/clearSearchResults';
export const SET_ADD_DEFAULT = 'search/setAddDefault';
//
// Action Creators
export const lookupArtist = createThunk(LOOKUP_ARTIST);
export const getSearchResults = createThunk(GET_SEARCH_RESULTS);
export const addArtist = createThunk(ADD_ARTIST);
export const clearAddArtist = createAction(CLEAR_ADD_ARTIST);
export const setAddArtistDefault = createAction(SET_ADD_ARTIST_DEFAULT);
export const setAddArtistValue = createAction(SET_ADD_ARTIST_VALUE, (payload) => {
return {
section,
...payload
};
});
export const addAlbum = createThunk(ADD_ALBUM);
export const clearSearchResults = createAction(CLEAR_SEARCH_RESULTS);
export const setAddDefault = createAction(SET_ADD_DEFAULT);
//
// Action Handlers
export const actionHandlers = handleThunks({
[LOOKUP_ARTIST]: function(getState, payload, dispatch) {
[GET_SEARCH_RESULTS]: function(getState, payload, dispatch) {
dispatch(set({ section, isFetching: true }));
if (abortCurrentRequest) {
@ -80,7 +74,7 @@ export const actionHandlers = handleThunks({
}
const { request, abortRequest } = createAjaxRequest({
url: '/artist/lookup',
url: '/search',
data: {
term: payload.term
}
@ -115,8 +109,9 @@ export const actionHandlers = handleThunks({
dispatch(set({ section, isAdding: true }));
const foreignArtistId = payload.foreignArtistId;
const items = getState().addArtist.items;
const newArtist = getNewArtist(_.cloneDeep(_.find(items, { foreignArtistId })), payload);
const items = getState().search.items;
const itemToAdd = _.find(items, { foreignId: foreignArtistId });
const newArtist = getNewArtist(_.cloneDeep(itemToAdd.artist), payload);
const promise = createAjaxRequest({
url: '/artist',
@ -138,6 +133,47 @@ export const actionHandlers = handleThunks({
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isAdding: false,
isAdded: false,
addError: xhr
}));
});
},
[ADD_ALBUM]: function(getState, payload, dispatch) {
dispatch(set({ section, isAdding: true }));
const foreignAlbumId = payload.foreignAlbumId;
const items = getState().search.items;
const itemToAdd = _.find(items, { foreignId: foreignAlbumId });
const newAlbum = getNewAlbum(_.cloneDeep(itemToAdd.album), payload);
const promise = createAjaxRequest({
url: '/album',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(newAlbum)
}).request;
promise.done((data) => {
data.releases = itemToAdd.album.releases;
itemToAdd.album = data;
dispatch(batchActions([
updateItem({ section: 'artist', ...data.artist }),
updateItem({ section, ...itemToAdd }),
set({
section,
isAdding: false,
isAdded: true,
addError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
@ -154,9 +190,7 @@ export const actionHandlers = handleThunks({
export const reducers = createHandleActions({
[SET_ADD_ARTIST_VALUE]: createSetSettingValueReducer(section),
[SET_ADD_ARTIST_DEFAULT]: function(state, { payload }) {
[SET_ADD_DEFAULT]: function(state, { payload }) {
const newState = getSectionState(state, section);
newState.defaults = {
@ -167,7 +201,7 @@ export const reducers = createHandleActions({
return updateSectionState(state, section, newState);
},
[CLEAR_ADD_ARTIST]: function(state) {
[CLEAR_SEARCH_RESULTS]: function(state) {
const {
defaults,
...otherDefaultState

View File

@ -0,0 +1,18 @@
import getNewArtist from 'Utilities/Artist/getNewArtist';
function getNewAlbum(album, payload) {
const {
searchForNewAlbum = false
} = payload;
getNewArtist(album.artist, payload);
album.addOptions = {
searchForNewAlbum
};
album.monitored = true;
return album;
}
export default getNewAlbum;

View File

@ -14,31 +14,51 @@ using NzbDrone.Core.Music.Events;
using NzbDrone.Core.MediaFiles.Events;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Validation.Paths;
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Validation;
namespace Lidarr.Api.V1.Albums
{
public class AlbumModule : AlbumModuleWithSignalR,
IHandle<AlbumGrabbedEvent>,
IHandle<AlbumEditedEvent>,
IHandle<AlbumUpdatedEvent>,
IHandle<AlbumImportedEvent>,
IHandle<TrackImportedEvent>,
IHandle<TrackFileDeletedEvent>
{
protected readonly IReleaseService _releaseService;
protected readonly IAddAlbumService _addAlbumService;
public AlbumModule(IAlbumService albumService,
IAddAlbumService addAlbumService,
IReleaseService releaseService,
IArtistStatisticsService artistStatisticsService,
IMapCoversToLocal coverMapper,
IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster)
IBroadcastSignalRMessage signalRBroadcaster,
ProfileExistsValidator profileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster)
{
_releaseService = releaseService;
_addAlbumService = addAlbumService;
GetResourceAll = GetAlbums;
CreateResource = AddAlbum;
UpdateResource = UpdateAlbum;
DeleteResource = DeleteAlbum;
Put("/monitor", x => SetAlbumsMonitored());
PostValidator.RuleFor(s => s.ForeignAlbumId).NotEmpty();
PostValidator.RuleFor(s => s.Artist.QualityProfileId).SetValidator(profileExistsValidator);
PostValidator.RuleFor(s => s.Artist.MetadataProfileId).SetValidator(metadataProfileExistsValidator);
PostValidator.RuleFor(s => s.Artist.RootFolderPath).IsValidPath().When(s => s.Artist.Path.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.Artist.ForeignArtistId).NotEmpty();
}
private List<AlbumResource> GetAlbums()
@ -66,6 +86,11 @@ namespace Lidarr.Api.V1.Albums
var album = _albumService.FindById(foreignAlbumId);
if (album == null)
{
return MapToResource(new List<Album>(), false);
}
if (includeAllArtistAlbumsQuery.HasValue && Convert.ToBoolean(includeAllArtistAlbumsQuery.Value))
{
return MapToResource(_albumService.GetAlbumsByArtist(album.ArtistId), false);
@ -85,6 +110,13 @@ namespace Lidarr.Api.V1.Albums
return MapToResource(_albumService.GetAlbums(albumIds), false);
}
private int AddAlbum(AlbumResource albumResource)
{
var album = _addAlbumService.AddAlbum(albumResource.ToModel());
return album.Id;
}
private void UpdateAlbum(AlbumResource albumResource)
{
var album = _albumService.GetAlbum(albumResource.Id);
@ -97,6 +129,14 @@ namespace Lidarr.Api.V1.Albums
BroadcastResourceChange(ModelAction.Updated, model.Id);
}
private void DeleteAlbum(int id)
{
var deleteFiles = Request.GetBooleanQueryParameter("deleteFiles");
var addImportListExclusion = Request.GetBooleanQueryParameter("addImportListExclusion");
_albumService.DeleteAlbum(id, deleteFiles, addImportListExclusion);
}
private object SetAlbumsMonitored()
{
var resource = Request.Body.FromJson<AlbumsMonitoredResource>();
@ -122,6 +162,16 @@ namespace Lidarr.Api.V1.Albums
BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true));
}
public void Handle(AlbumUpdatedEvent message)
{
BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true));
}
public void Handle(AlbumDeletedEvent message)
{
BroadcastResourceChange(ModelAction.Deleted, message.Album.ToResource());
}
public void Handle(AlbumImportedEvent message)
{
BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true));

View File

@ -43,7 +43,7 @@ namespace Lidarr.Api.V1.Albums
public List<MediaCover> Images { get; set; }
public List<Links> Links { get; set; }
public AlbumStatisticsResource Statistics { get; set; }
public AddAlbumOptions AddOptions { get; set; }
public string RemoteCover { get; set; }
//Hiding this so people don't think its usable (only used to set the initial state)
@ -88,6 +88,8 @@ namespace Lidarr.Api.V1.Albums
{
if (resource == null) return null;
var artist = resource.Artist?.ToModel() ?? new NzbDrone.Core.Music.Artist();
return new Album
{
Id = resource.Id,
@ -96,9 +98,13 @@ namespace Lidarr.Api.V1.Albums
Disambiguation = resource.Disambiguation,
Overview = resource.Overview,
Images = resource.Images,
AlbumType = resource.AlbumType,
Monitored = resource.Monitored,
AnyReleaseOk = resource.AnyReleaseOk,
AlbumReleases = resource.Releases.ToModel()
AlbumReleases = resource.Releases.ToModel(),
AddOptions = resource.AddOptions,
Artist = artist,
ArtistMetadata = artist.Metadata.Value
};
}

View File

@ -12,7 +12,7 @@ namespace Lidarr.Api.V1.Profiles.Metadata
public MetadataProfileModule(IMetadataProfileService profileService)
{
_profileService = profileService;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name).NotEqual("None").WithMessage("'None' is a reserved profile name").NotEmpty();
SharedValidator.RuleFor(c => c.PrimaryAlbumTypes).MustHaveAllowedPrimaryType();
SharedValidator.RuleFor(c => c.SecondaryAlbumTypes).MustHaveAllowedSecondaryType();
SharedValidator.RuleFor(c => c.ReleaseStatuses).MustHaveAllowedReleaseStatus();

View File

@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using Nancy;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using Lidarr.Http;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.Albums;
using System;
namespace Lidarr.Api.V1.Search
{
public class SearchModule : LidarrRestModule<SearchResource>
{
private readonly ISearchForNewEntity _searchProxy;
public SearchModule(ISearchForNewEntity searchProxy)
: base("/search")
{
_searchProxy = searchProxy;
Get("/", x => Search());
}
private object Search()
{
var searchResults = _searchProxy.SearchForNewEntity((string)Request.Query.term);
return MapToResource(searchResults).ToList();
}
private static IEnumerable<SearchResource> MapToResource(IEnumerable<Object> results)
{
int id = 1;
foreach (var result in results)
{
var resource = new SearchResource();
resource.Id = id++;
if (result is NzbDrone.Core.Music.Artist)
{
var artist = (NzbDrone.Core.Music.Artist) result;
resource.Artist = artist.ToResource();
resource.ForeignId = artist.ForeignArtistId;
var poster = artist.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
if (poster != null)
{
resource.Artist.RemotePoster = poster.Url;
}
}
else if (result is NzbDrone.Core.Music.Album)
{
var album = (NzbDrone.Core.Music.Album) result;
resource.Album = album.ToResource();
resource.ForeignId = album.ForeignAlbumId;
var cover = album.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover);
if (cover != null)
{
resource.Album.RemoteCover = cover.Url;
}
}
else
{
throw new NotImplementedException("Bad response from search all proxy");
}
yield return resource;
}
}
}
}

View File

@ -0,0 +1,14 @@
using Lidarr.Http.REST;
using Lidarr.Api.V1.Artist;
using Lidarr.Api.V1.Albums;
namespace Lidarr.Api.V1.Search
{
public class
SearchResource : RestResource
{
public string ForeignId { get; set; }
public ArtistResource Artist { get; set; }
public AlbumResource Album { get; set; }
}
}

View File

@ -73,7 +73,7 @@ namespace NzbDrone.Automation.Test
page.WaitForNoSpinner();
page.Find(By.CssSelector("input[class*='AddNewArtist-searchInput']")).Should().NotBeNull();
page.Find(By.CssSelector("input[class*='AddNewItem-searchInput']")).Should().NotBeNull();
}
}
}

View File

@ -115,7 +115,7 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
.Returns(default(RemoteAlbum));
// handle deletion event
Subject.Handle(new AlbumDeletedEvent(remoteAlbum.Albums.First(), false));
Subject.Handle(new AlbumDeletedEvent(remoteAlbum.Albums.First(), false, false));
// verify download has null remote album
var trackedDownloads = Subject.GetTrackedDownloads();

View File

@ -72,6 +72,13 @@ namespace NzbDrone.Core.Test.ImportListTests
.Returns(new Artist{ForeignArtistId = _importListReports.First().ArtistMusicBrainzId });
}
private void WithExistingAlbum()
{
Mocker.GetMock<IAlbumService>()
.Setup(v => v.FindById(_importListReports.First().AlbumMusicBrainzId))
.Returns(new Album{ForeignAlbumId = _importListReports.First().AlbumMusicBrainzId });
}
private void WithExcludedArtist()
{
Mocker.GetMock<IImportListExclusionService>()
@ -83,6 +90,17 @@ namespace NzbDrone.Core.Test.ImportListTests
});
}
private void WithExcludedAlbum()
{
Mocker.GetMock<IImportListExclusionService>()
.Setup(v => v.All())
.Returns(new List<ImportListExclusion> {
new ImportListExclusion {
ForeignId = "09474d62-17dd-3a4f-98fb-04c65f38a479"
}
});
}
private void WithMonitorType(ImportListMonitorType monitor)
{
Mocker.GetMock<IImportListFactory>()
@ -120,17 +138,7 @@ namespace NzbDrone.Core.Test.ImportListTests
}
[Test]
public void should_search_with_lidarr_id_if_album_id_and_no_artist_id()
{
WithAlbumId();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<ISearchForNewAlbum>()
.Verify(v => v.SearchForNewAlbum($"lidarr:{_importListReports.First().AlbumMusicBrainzId}", null), Times.Once());
}
[Test]
public void should_not_search_if_album_id_and_artist_id()
public void should_not_search_if_album_title_and_album_id()
{
WithArtistId();
WithAlbumId();
@ -159,8 +167,6 @@ namespace NzbDrone.Core.Test.ImportListTests
public void should_not_add_if_existing_artist()
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithExistingArtist();
Subject.Execute(new ImportListSyncCommand());
@ -169,14 +175,37 @@ namespace NzbDrone.Core.Test.ImportListTests
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t=>t.Count == 0)));
}
[Test]
public void should_not_add_if_existing_album()
{
WithAlbumId();
WithExistingAlbum();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddArtistService>()
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t=>t.Count == 0)));
}
[Test]
public void should_add_if_existing_artist_but_new_album()
{
WithAlbumId();
WithExistingArtist();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddAlbumService>()
.Verify(v => v.AddAlbums(It.Is<List<Album>>(t=>t.Count == 1)));
}
[TestCase(ImportListMonitorType.None, false)]
[TestCase(ImportListMonitorType.SpecificAlbum, true)]
[TestCase(ImportListMonitorType.EntireArtist, true)]
public void should_add_if_not_existing_artist(ImportListMonitorType monitor, bool expectedArtistMonitored)
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithMonitorType(monitor);
Subject.Execute(new ImportListSyncCommand());
@ -185,12 +214,24 @@ namespace NzbDrone.Core.Test.ImportListTests
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 1 && t.First().Monitored == expectedArtistMonitored)));
}
[TestCase(ImportListMonitorType.None, false)]
[TestCase(ImportListMonitorType.SpecificAlbum, true)]
[TestCase(ImportListMonitorType.EntireArtist, true)]
public void should_add_if_not_existing_album(ImportListMonitorType monitor, bool expectedAlbumMonitored)
{
WithAlbumId();
WithMonitorType(monitor);
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddAlbumService>()
.Verify(v => v.AddAlbums(It.Is<List<Album>>(t => t.Count == 1 && t.First().Monitored == expectedAlbumMonitored)));
}
[Test]
public void should_not_add_if_excluded_artist()
public void should_not_add_artist_if_excluded_artist()
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithExcludedArtist();
Subject.Execute(new ImportListSyncCommand());
@ -200,42 +241,28 @@ namespace NzbDrone.Core.Test.ImportListTests
}
[Test]
public void should_mark_album_for_monitor_if_album_id_and_specific_monitor_selected()
public void should_not_add_album_if_excluded_album()
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithMonitorType(ImportListMonitorType.SpecificAlbum);
WithExcludedAlbum();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddArtistService>()
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 1 && t.First().AddOptions.AlbumsToMonitor.Contains("09474d62-17dd-3a4f-98fb-04c65f38a479"))));
Mocker.GetMock<IAddAlbumService>()
.Verify(v => v.AddAlbums(It.Is<List<Album>>(t => t.Count == 0)));
}
[Test]
public void should_not_mark_album_for_monitor_if_album_id_and_monitor_all_selected()
public void should_not_add_album_if_excluded_artist()
{
WithArtistId();
WithAlbum();
WithAlbumId();
WithMonitorType(ImportListMonitorType.EntireArtist);
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddArtistService>()
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 1 && !t.First().AddOptions.AlbumsToMonitor.Any())));
}
[Test]
public void should_not_mark_album_for_monitor_if_no_album_id()
{
WithArtistId();
WithExcludedArtist();
Subject.Execute(new ImportListSyncCommand());
Mocker.GetMock<IAddArtistService>()
.Verify(v => v.AddArtists(It.Is<List<Artist>>(t => t.Count == 1 && t.First().AddOptions.AlbumsToMonitor.Count == 0)));
Mocker.GetMock<IAddAlbumService>()
.Verify(v => v.AddAlbums(It.Is<List<Album>>(t => t.Count == 0)));
}
}
}

View File

@ -15,7 +15,6 @@ using NzbDrone.Core.MetadataSource.SkyHook.Resource;
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
{
[TestFixture]
[IntegrationTest]
public class SkyHookProxyFixture : CoreTest<SkyHookProxy>
{
private MetadataProfile _metadataProfile;

View File

@ -3,16 +3,15 @@ using NUnit.Framework;
using NzbDrone.Core.MetadataSource.SkyHook;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
using NzbDrone.Test.Common.Categories;
using Moq;
using NzbDrone.Core.Profiles.Metadata;
using NzbDrone.Core.Music;
using System.Collections.Generic;
using System;
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
{
[TestFixture]
[IntegrationTest]
public class SkyHookProxySearchFixture : CoreTest<SkyHookProxy>
{
[SetUp]
@ -107,5 +106,28 @@ namespace NzbDrone.Core.Test.MetadataSource.SkyHook
ExceptionVerification.IgnoreWarns();
}
[TestCase("Eminem", 0, typeof(Artist), "Eminem")]
[TestCase("Eminem Kamikaze", 0, typeof(Artist), "Eminem")]
[TestCase("Eminem Kamikaze", 1, typeof(Album), "Kamikaze")]
public void successful_combined_search(string query, int position, Type resultType, string expected)
{
var result = Subject.SearchForNewEntity(query);
result.Should().NotBeEmpty();
result[position].GetType().Should().Be(resultType);
if (resultType == typeof(Artist))
{
var cast = result[position] as Artist;
cast.Should().NotBeNull();
cast.Name.Should().Be(expected);
}
else
{
var cast = result[position] as Album;
cast.Should().NotBeNull();
cast.Title.Should().Be(expected);
}
}
}
}

View File

@ -0,0 +1,96 @@
using System.Collections.Generic;
using FizzWare.NBuilder;
using FluentAssertions;
using FluentValidation;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.Music;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
using System;
namespace NzbDrone.Core.Test.MusicTests
{
[TestFixture]
public class AddAlbumFixture : CoreTest<AddAlbumService>
{
private Artist _fakeArtist;
private Album _fakeAlbum;
[SetUp]
public void Setup()
{
_fakeAlbum = Builder<Album>
.CreateNew()
.Build();
_fakeArtist = Builder<Artist>
.CreateNew()
.With(s => s.Path = null)
.With(s => s.Metadata = Builder<ArtistMetadata>.CreateNew().Build())
.Build();
}
private void GivenValidAlbum(string lidarrId)
{
Mocker.GetMock<IProvideAlbumInfo>()
.Setup(s => s.GetAlbumInfo(lidarrId))
.Returns(Tuple.Create(_fakeArtist.Metadata.Value.ForeignArtistId,
_fakeAlbum,
new List<ArtistMetadata> { _fakeArtist.Metadata.Value }));
Mocker.GetMock<IAddArtistService>()
.Setup(s => s.AddArtist(It.IsAny<Artist>(), It.IsAny<bool>()))
.Returns(_fakeArtist);
}
private void GivenValidPath()
{
Mocker.GetMock<IBuildFileNames>()
.Setup(s => s.GetArtistFolder(It.IsAny<Artist>(), null))
.Returns<Artist, NamingConfig>((c, n) => c.Name);
}
private Album AlbumToAdd(string albumId, string artistId)
{
return new Album
{
ForeignAlbumId = albumId,
ArtistMetadata = new ArtistMetadata
{
ForeignArtistId = artistId
}
};
}
[Test]
public void should_be_able_to_add_a_album_without_passing_in_name()
{
var newAlbum = AlbumToAdd("5537624c-3d2f-4f5c-8099-df916082c85c", "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493");
GivenValidAlbum(newAlbum.ForeignAlbumId);
GivenValidPath();
var album = Subject.AddAlbum(newAlbum);
album.Title.Should().Be(_fakeAlbum.Title);
}
[Test]
public void should_throw_if_album_cannot_be_found()
{
var newAlbum = AlbumToAdd("5537624c-3d2f-4f5c-8099-df916082c85c", "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493");
Mocker.GetMock<IProvideAlbumInfo>()
.Setup(s => s.GetAlbumInfo(newAlbum.ForeignAlbumId))
.Throws(new AlbumNotFoundException(newAlbum.ForeignAlbumId));
Assert.Throws<ValidationException>(() => Subject.AddAlbum(newAlbum));
ExceptionVerification.ExpectedErrors(1);
}
}
}

View File

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.IO;
using FizzWare.NBuilder;

View File

@ -172,7 +172,7 @@ namespace NzbDrone.Core.Test.MusicTests
Subject.RefreshAlbumInfo(album, null, false);
Mocker.GetMock<IAlbumService>()
.Verify(x => x.DeleteAlbum(album.Id, true),
.Verify(x => x.DeleteAlbum(album.Id, true, false),
Times.Once());
ExceptionVerification.ExpectedWarns(1);

View File

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
@ -14,6 +12,7 @@ using NzbDrone.Test.Common;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.History;
using NzbDrone.Core.Music.Events;
using NzbDrone.Core.ImportLists.Exclusions;
namespace NzbDrone.Core.Test.MusicTests
{
@ -24,6 +23,7 @@ namespace NzbDrone.Core.Test.MusicTests
private Album _album1;
private Album _album2;
private List<Album> _albums;
private List<Album> _remoteAlbums;
[SetUp]
public void Setup()
@ -38,6 +38,9 @@ namespace NzbDrone.Core.Test.MusicTests
_albums = new List<Album> {_album1, _album2};
_remoteAlbums = _albums.JsonClone();
_remoteAlbums.ForEach(x => x.Id = 0);
var metadata = Builder<ArtistMetadata>.CreateNew().Build();
_artist = Builder<Artist>.CreateNew()
@ -62,6 +65,10 @@ namespace NzbDrone.Core.Test.MusicTests
Mocker.GetMock<IHistoryService>()
.Setup(x => x.GetByArtist(It.IsAny<int>(), It.IsAny<HistoryEventType?>()))
.Returns(new List<History.History>());
Mocker.GetMock<IImportListExclusionService>()
.Setup(x => x.FindByForeignId(It.IsAny<List<string>>()))
.Returns(new List<ImportListExclusion>());
}
private void GivenNewArtistInfo(Artist artist)
@ -78,11 +85,11 @@ namespace NzbDrone.Core.Test.MusicTests
.Returns(Builder<TrackFile>.CreateListOfSize(1).BuildList());
}
private void GivenAlbumsForRefresh()
private void GivenAlbumsForRefresh(List<Album> albums)
{
Mocker.GetMock<IAlbumService>(MockBehavior.Strict)
.Setup(s => s.GetAlbumsForRefresh(It.IsAny<int>(), It.IsAny<IEnumerable<string>>()))
.Returns(new List<Album>());
.Returns(albums);
}
private void AllowArtistUpdate()
@ -97,10 +104,10 @@ namespace NzbDrone.Core.Test.MusicTests
{
var newArtistInfo = _artist.JsonClone();
newArtistInfo.Metadata = _artist.Metadata.Value.JsonClone();
newArtistInfo.Albums = _albums;
newArtistInfo.Albums = _remoteAlbums;
GivenNewArtistInfo(newArtistInfo);
GivenAlbumsForRefresh();
GivenAlbumsForRefresh(_albums);
AllowArtistUpdate();
Subject.Execute(new RefreshArtistCommand(_artist.Id));
@ -117,10 +124,10 @@ namespace NzbDrone.Core.Test.MusicTests
newArtistInfo.Metadata.Value.Images = new List<MediaCover.MediaCover> {
new MediaCover.MediaCover(MediaCover.MediaCoverTypes.Logo, "dummy")
};
newArtistInfo.Albums = _albums;
newArtistInfo.Albums = _remoteAlbums;
GivenNewArtistInfo(newArtistInfo);
GivenAlbumsForRefresh();
GivenAlbumsForRefresh(new List<Album>());
AllowArtistUpdate();
Subject.Execute(new RefreshArtistCommand(_artist.Id));
@ -151,7 +158,7 @@ namespace NzbDrone.Core.Test.MusicTests
public void should_log_error_but_not_delete_if_musicbrainz_id_not_found_and_artist_has_files()
{
GivenArtistFiles();
GivenAlbumsForRefresh();
GivenAlbumsForRefresh(new List<Album>());
Subject.Execute(new RefreshArtistCommand(_artist.Id));
@ -169,7 +176,7 @@ namespace NzbDrone.Core.Test.MusicTests
{
var newArtistInfo = _artist.JsonClone();
newArtistInfo.Metadata = _artist.Metadata.Value.JsonClone();
newArtistInfo.Albums = _albums;
newArtistInfo.Albums = _remoteAlbums;
newArtistInfo.ForeignArtistId = _artist.ForeignArtistId + 1;
newArtistInfo.Metadata.Value.Id = 100;
@ -222,8 +229,7 @@ namespace NzbDrone.Core.Test.MusicTests
var newArtistInfo = clash.JsonClone();
newArtistInfo.Metadata = clash.Metadata.Value.JsonClone();
newArtistInfo.Albums = _albums.JsonClone();
newArtistInfo.Albums.Value.ForEach(x => x.Id = 0);
newArtistInfo.Albums = _remoteAlbums;
GivenNewArtistInfo(newArtistInfo);

View File

@ -3,11 +3,12 @@ using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.ImportLists.HeadphonesImport;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Core.Music;
using NzbDrone.Core.Profiles.Metadata;
using System.Collections.Generic;
using System;
namespace NzbDrone.Core.Test.Profiles.Metadata
{
@ -21,7 +22,7 @@ namespace NzbDrone.Core.Test.Profiles.Metadata
Subject.Handle(new ApplicationStartedEvent());
Mocker.GetMock<IMetadataProfileRepository>()
.Verify(v => v.Insert(It.IsAny<MetadataProfile>()), Times.Once());
.Verify(v => v.Insert(It.IsAny<MetadataProfile>()), Times.Exactly(2));
}
[Test]
@ -36,9 +37,72 @@ namespace NzbDrone.Core.Test.Profiles.Metadata
Subject.Handle(new ApplicationStartedEvent());
Mocker.GetMock<IMetadataProfileRepository>()
.Verify(v => v.Insert(It.IsAny<MetadataProfile>()), Times.Never());
.Verify(v => v.Insert(It.Is<MetadataProfile>(x => x.Name == "Standard")), Times.Never());
}
[Test]
public void init_should_add_none_profile_if_it_doesnt_exist()
{
Mocker.GetMock<IMetadataProfileRepository>()
.Setup(s => s.All())
.Returns(Builder<MetadataProfile>.CreateListOfSize(2).Build().ToList());
Subject.Handle(new ApplicationStartedEvent());
Mocker.GetMock<IMetadataProfileRepository>()
.Verify(v => v.Insert(It.Is<MetadataProfile>(x => x.Name == "None")), Times.Once());
}
[Test]
public void init_should_move_existing_none_profile()
{
var profiles = Builder<MetadataProfile>.CreateListOfSize(2)
.TheFirst(1)
.With(x => x.Name = MetadataProfileService.NONE_PROFILE_NAME)
.With(x => x.PrimaryAlbumTypes = new List<ProfilePrimaryAlbumTypeItem>
{
new ProfilePrimaryAlbumTypeItem
{
PrimaryAlbumType = PrimaryAlbumType.Album,
Allowed = true
}
})
.BuildList();
Mocker.GetMock<IMetadataProfileRepository>()
.Setup(s => s.All())
.Returns(profiles);
Subject.Handle(new ApplicationStartedEvent());
Mocker.GetMock<IMetadataProfileRepository>()
.Verify(v => v.Update(It.Is<MetadataProfile>(x => x.Id == profiles.First().Id && x.Name == "None.1")), Times.Once());
Mocker.GetMock<IMetadataProfileRepository>()
.Verify(v => v.Insert(It.Is<MetadataProfile>(x => x.Name == "None")), Times.Once());
}
[Test]
public void should_not_be_able_to_edit_none_profile()
{
var profile = Builder<MetadataProfile>.CreateNew()
.With(p => p.Name = MetadataProfileService.NONE_PROFILE_NAME)
.Build();
Assert.Throws<InvalidOperationException>(() => Subject.Update(profile));
}
[Test]
public void should_not_be_able_to_delete_none_profile()
{
var profile = Builder<MetadataProfile>.CreateNew()
.With(p => p.Name = MetadataProfileService.NONE_PROFILE_NAME)
.Build();
Mocker.GetMock<IMetadataProfileRepository>().Setup(c => c.Get(profile.Id)).Returns(profile);
Assert.Throws<MetadataProfileInUseException>(() => Subject.Delete(profile.Id));
}
[Test]
public void should_not_be_able_to_delete_profile_if_assigned_to_artist()
@ -94,10 +158,13 @@ namespace NzbDrone.Core.Test.Profiles.Metadata
}
[Test]
public void should_delete_profile_if_not_assigned_to_artist_or_import_list()
{
var profile = Builder<MetadataProfile>.CreateNew()
.With(p => p.Id = 1)
.Build();
var artistList = Builder<Artist>.CreateListOfSize(3)
.All()
.With(c => c.MetadataProfileId = 2)
@ -110,6 +177,7 @@ namespace NzbDrone.Core.Test.Profiles.Metadata
Mocker.GetMock<IArtistService>().Setup(c => c.GetAllArtists()).Returns(artistList);
Mocker.GetMock<IImportListFactory>().Setup(c => c.All()).Returns(importLists);
Mocker.GetMock<IMetadataProfileRepository>().Setup(c => c.Get(profile.Id)).Returns(profile);
Subject.Delete(1);

View File

@ -17,6 +17,8 @@ namespace NzbDrone.Core.ArtistStats
public class ArtistStatisticsService : IArtistStatisticsService,
IHandle<ArtistUpdatedEvent>,
IHandle<ArtistDeletedEvent>,
IHandle<AlbumAddedEvent>,
IHandle<AlbumDeletedEvent>,
IHandle<AlbumImportedEvent>,
IHandle<AlbumEditedEvent>,
IHandle<TrackFileDeletedEvent>
@ -77,6 +79,20 @@ namespace NzbDrone.Core.ArtistStats
_cache.Remove(message.Artist.Id.ToString());
}
[EventHandleOrder(EventHandleOrder.First)]
public void Handle(AlbumAddedEvent message)
{
_cache.Remove("AllArtists");
_cache.Remove(message.Album.ArtistId.ToString());
}
[EventHandleOrder(EventHandleOrder.First)]
public void Handle(AlbumDeletedEvent message)
{
_cache.Remove("AllArtists");
_cache.Remove(message.Album.ArtistId.ToString());
}
[EventHandleOrder(EventHandleOrder.First)]
public void Handle(AlbumImportedEvent message)
{

View File

@ -1,5 +1,6 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
using System.Collections.Generic;
using System.Linq;
namespace NzbDrone.Core.ImportLists.Exclusions
@ -7,6 +8,7 @@ namespace NzbDrone.Core.ImportLists.Exclusions
public interface IImportListExclusionRepository : IBasicRepository<ImportListExclusion>
{
ImportListExclusion FindByForeignId(string foreignId);
List<ImportListExclusion> FindByForeignId(List<string> ids);
}
public class ImportListExclusionRepository : BasicRepository<ImportListExclusion>, IImportListExclusionRepository
@ -20,5 +22,10 @@ namespace NzbDrone.Core.ImportLists.Exclusions
{
return Query.Where<ImportListExclusion>(m => m.ForeignId == foreignId).SingleOrDefault();
}
public List<ImportListExclusion> FindByForeignId(List<string> ids)
{
return Query.Where($"[ForeignId] IN ('{string.Join("', '", ids)}')").ToList();
}
}
}

View File

@ -10,12 +10,16 @@ namespace NzbDrone.Core.ImportLists.Exclusions
ImportListExclusion Add(ImportListExclusion importListExclusion);
List<ImportListExclusion> All();
void Delete(int id);
void Delete(string foreignId);
ImportListExclusion Get(int id);
ImportListExclusion FindByForeignId(string foreignId);
List<ImportListExclusion> FindByForeignId(List<string> foreignIds);
ImportListExclusion Update(ImportListExclusion importListExclusion);
}
public class ImportListExclusionService : IImportListExclusionService, IHandleAsync<ArtistDeletedEvent>
public class ImportListExclusionService : IImportListExclusionService,
IHandleAsync<ArtistDeletedEvent>,
IHandleAsync<AlbumDeletedEvent>
{
private readonly IImportListExclusionRepository _repo;
@ -39,6 +43,15 @@ namespace NzbDrone.Core.ImportLists.Exclusions
_repo.Delete(id);
}
public void Delete(string foreignId)
{
var exclusion = FindByForeignId(foreignId);
if (exclusion != null)
{
Delete(exclusion.Id);
}
}
public ImportListExclusion Get(int id)
{
return _repo.Get(id);
@ -49,6 +62,11 @@ namespace NzbDrone.Core.ImportLists.Exclusions
return _repo.FindByForeignId(foreignId);
}
public List<ImportListExclusion> FindByForeignId(List<string> foreignIds)
{
return _repo.FindByForeignId(foreignIds);
}
public List<ImportListExclusion> All()
{
return _repo.All().ToList();
@ -76,5 +94,28 @@ namespace NzbDrone.Core.ImportLists.Exclusions
_repo.Insert(importExclusion);
}
public void HandleAsync(AlbumDeletedEvent message)
{
if (!message.AddImportListExclusion)
{
return;
}
var existingExclusion = _repo.FindByForeignId(message.Album.ForeignAlbumId);
if (existingExclusion != null)
{
return;
}
var importExclusion = new ImportListExclusion
{
ForeignId = message.Album.ForeignAlbumId,
Name = $"{message.Album.ArtistMetadata.Value.Name} - {message.Album.Title}"
};
_repo.Insert(importExclusion);
}
}
}

View File

@ -20,19 +20,23 @@ namespace NzbDrone.Core.ImportLists
private readonly ISearchForNewAlbum _albumSearchService;
private readonly ISearchForNewArtist _artistSearchService;
private readonly IArtistService _artistService;
private readonly IAlbumService _albumService;
private readonly IAddArtistService _addArtistService;
private readonly IAddAlbumService _addAlbumService;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
public ImportListSyncService(IImportListFactory importListFactory,
IImportListExclusionService importListExclusionService,
IFetchAndParseImportList listFetcherAndParser,
ISearchForNewAlbum albumSearchService,
ISearchForNewArtist artistSearchService,
IArtistService artistService,
IAddArtistService addArtistService,
IEventAggregator eventAggregator,
Logger logger)
IImportListExclusionService importListExclusionService,
IFetchAndParseImportList listFetcherAndParser,
ISearchForNewAlbum albumSearchService,
ISearchForNewArtist artistSearchService,
IArtistService artistService,
IAlbumService albumService,
IAddArtistService addArtistService,
IAddAlbumService addAlbumService,
IEventAggregator eventAggregator,
Logger logger)
{
_importListFactory = importListFactory;
_importListExclusionService = importListExclusionService;
@ -40,7 +44,9 @@ namespace NzbDrone.Core.ImportLists
_albumSearchService = albumSearchService;
_artistSearchService = artistSearchService;
_artistService = artistService;
_albumService = albumService;
_addArtistService = addArtistService;
_addAlbumService = addAlbumService;
_eventAggregator = eventAggregator;
_logger = logger;
}
@ -74,6 +80,7 @@ namespace NzbDrone.Core.ImportLists
{
var processed = new List<Album>();
var artistsToAdd = new List<Artist>();
var albumsToAdd = new List<Album>();
_logger.ProgressInfo("Processing {0} list items", reports.Count);
@ -89,68 +96,156 @@ namespace NzbDrone.Core.ImportLists
var importList = _importListFactory.Get(report.ImportListId);
// Map MBid if we only have an album title
if (report.AlbumMusicBrainzId.IsNullOrWhiteSpace() && report.Album.IsNotNullOrWhiteSpace())
if (report.Album.IsNotNullOrWhiteSpace() || report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace())
{
var mappedAlbum = _albumSearchService.SearchForNewAlbum(report.Album, report.Artist)
.FirstOrDefault();
if (mappedAlbum == null) continue; // Break if we are looking for an album and cant find it. This will avoid us from adding the artist and possibly getting it wrong.
report.AlbumMusicBrainzId = mappedAlbum.ForeignAlbumId;
report.Album = mappedAlbum.Title;
report.Artist = mappedAlbum.ArtistMetadata?.Value?.Name;
report.ArtistMusicBrainzId = mappedAlbum?.ArtistMetadata?.Value?.ForeignArtistId;
if (report.AlbumMusicBrainzId.IsNullOrWhiteSpace() || report.ArtistMusicBrainzId.IsNullOrWhiteSpace())
{
MapAlbumReport(report);
}
ProcessAlbumReport(importList, report, listExclusions, albumsToAdd);
}
// Map artist ID if we only have album ID
if (report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() && report.ArtistMusicBrainzId.IsNullOrWhiteSpace())
else if (report.Artist.IsNotNullOrWhiteSpace() || report.ArtistMusicBrainzId.IsNotNullOrWhiteSpace())
{
var mappedAlbum = _albumSearchService.SearchForNewAlbum($"lidarr:{report.AlbumMusicBrainzId}", null)
.FirstOrDefault();
if (report.ArtistMusicBrainzId.IsNullOrWhiteSpace())
{
MapArtistReport(report);
}
if (mappedAlbum == null) continue;
report.Artist = mappedAlbum.ArtistMetadata?.Value?.Name;
report.ArtistMusicBrainzId = mappedAlbum?.ArtistMetadata?.Value?.ForeignArtistId;
ProcessArtistReport(importList, report, listExclusions, artistsToAdd);
}
}
// Map MBid if we only have a artist name
if (report.ArtistMusicBrainzId.IsNullOrWhiteSpace() && report.Artist.IsNotNullOrWhiteSpace())
{
var mappedArtist = _artistSearchService.SearchForNewArtist(report.Artist)
.FirstOrDefault();
report.ArtistMusicBrainzId = mappedArtist?.Metadata.Value?.ForeignArtistId;
report.Artist = mappedArtist?.Metadata.Value?.Name;
}
_addArtistService.AddArtists(artistsToAdd);
_addAlbumService.AddAlbums(albumsToAdd);
// Check to see if artist in DB
var existingArtist = _artistService.FindById(report.ArtistMusicBrainzId);
var message = string.Format($"Import List Sync Completed. Items found: {reports.Count}, Artists added: {artistsToAdd.Count}, Albums added: {albumsToAdd.Count}");
// TODO: Rework this for albums when we can add albums seperate from Artists
// (If list contains albums we should not break for an existing artist, we should add new albums that are not in DB)
if (existingArtist != null)
{
_logger.Debug("{0} [{1}] Rejected, Artist Exists in DB", report.ArtistMusicBrainzId, report.Artist);
continue;
}
_logger.ProgressInfo(message);
// Check to see if artist excluded
var excludedArtist = listExclusions.Where(s => s.ForeignId == report.ArtistMusicBrainzId).SingleOrDefault();
return processed;
}
if (excludedArtist != null)
{
_logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.ArtistMusicBrainzId, report.Artist);
continue;
}
private void MapAlbumReport(ImportListItemInfo report)
{
var albumQuery = report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() ? $"lidarr:{report.AlbumMusicBrainzId}" : report.Album;
var mappedAlbum = _albumSearchService.SearchForNewAlbum(albumQuery, report.Artist)
.FirstOrDefault();
// Append Artist if not already in DB or already on add list
if (artistsToAdd.All(s => s.Metadata.Value.ForeignArtistId != report.ArtistMusicBrainzId))
{
var monitored = importList.ShouldMonitor != ImportListMonitorType.None;
// Break if we are looking for an album and cant find it. This will avoid us from adding the artist and possibly getting it wrong.
if (mappedAlbum == null)
{
return;
}
artistsToAdd.Add(new Artist
report.AlbumMusicBrainzId = mappedAlbum.ForeignAlbumId;
report.Album = mappedAlbum.Title;
report.Artist = mappedAlbum.ArtistMetadata?.Value?.Name;
report.ArtistMusicBrainzId = mappedAlbum.ArtistMetadata?.Value?.ForeignArtistId;
}
private void ProcessAlbumReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Album> albumsToAdd)
{
if (report.AlbumMusicBrainzId == null)
{
return;
}
// Check to see if album in DB
var existingAlbum = _albumService.FindById(report.AlbumMusicBrainzId);
if (existingAlbum != null)
{
_logger.Debug("{0} [{1}] Rejected, Album Exists in DB", report.AlbumMusicBrainzId, report.Album);
return;
}
// Check to see if album excluded
var excludedAlbum = listExclusions.SingleOrDefault(s => s.ForeignId == report.AlbumMusicBrainzId);
if (excludedAlbum != null)
{
_logger.Debug("{0} [{1}] Rejected due to list exlcusion", report.AlbumMusicBrainzId, report.Album);
return;
}
// Check to see if artist excluded
var excludedArtist = listExclusions.SingleOrDefault(s => s.ForeignId == report.ArtistMusicBrainzId);
if (excludedArtist != null)
{
_logger.Debug("{0} [{1}] Rejected due to list exlcusion for parent artist", report.AlbumMusicBrainzId, report.Album);
return;
}
// Append Album if not already in DB or already on add list
if (albumsToAdd.All(s => s.ForeignAlbumId != report.AlbumMusicBrainzId))
{
var monitored = importList.ShouldMonitor != ImportListMonitorType.None;
albumsToAdd.Add(new Album
{
ForeignAlbumId = report.AlbumMusicBrainzId,
Monitored = monitored,
Artist = new Artist
{
Monitored = monitored,
RootFolderPath = importList.RootFolderPath,
QualityProfileId = importList.ProfileId,
MetadataProfileId = importList.MetadataProfileId,
Tags = importList.Tags,
AlbumFolder = true,
AddOptions = new AddArtistOptions
{
SearchForMissingAlbums = monitored,
Monitored = monitored,
Monitor = monitored ? MonitorTypes.All : MonitorTypes.None
}
},
});
}
}
private void MapArtistReport(ImportListItemInfo report)
{
var mappedArtist = _artistSearchService.SearchForNewArtist(report.Artist)
.FirstOrDefault();
report.ArtistMusicBrainzId = mappedArtist?.Metadata.Value?.ForeignArtistId;
report.Artist = mappedArtist?.Metadata.Value?.Name;
}
private void ProcessArtistReport(ImportListDefinition importList, ImportListItemInfo report, List<ImportListExclusion> listExclusions, List<Artist> artistsToAdd)
{
if (report.ArtistMusicBrainzId == null)
{
return;
}
// Check to see if artist in DB
var existingArtist = _artistService.FindById(report.ArtistMusicBrainzId);
if (existingArtist != null)
{
_logger.Debug("{0} [{1}] Rejected, Artist Exists in DB", report.ArtistMusicBrainzId, report.Artist);
return;
}
// 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);
return;
}
// Append Artist if not already in DB or already on add list
if (artistsToAdd.All(s => s.Metadata.Value.ForeignArtistId != report.ArtistMusicBrainzId))
{
var monitored = importList.ShouldMonitor != ImportListMonitorType.None;
artistsToAdd.Add(new Artist
{
Metadata = new ArtistMetadata
{
@ -170,22 +265,7 @@ namespace NzbDrone.Core.ImportLists
Monitor = monitored ? MonitorTypes.All : MonitorTypes.None
}
});
}
// Add Album so we know what to monitor
if (report.AlbumMusicBrainzId.IsNotNullOrWhiteSpace() && artistsToAdd.Any(s => s.Metadata.Value.ForeignArtistId == report.ArtistMusicBrainzId) && importList.ShouldMonitor == ImportListMonitorType.SpecificAlbum)
{
artistsToAdd.Find(s => s.Metadata.Value.ForeignArtistId == report.ArtistMusicBrainzId).AddOptions.AlbumsToMonitor.Add(report.AlbumMusicBrainzId);
}
}
_addArtistService.AddArtists(artistsToAdd);
var message = string.Format("Import List Sync Completed. Items found: {0}, Artists added: {1}", reports.Count, artistsToAdd.Count);
_logger.ProgressInfo(message);
return processed;
}
public void Execute(ImportListSyncCommand message)

View File

@ -26,6 +26,7 @@ namespace NzbDrone.Core.MediaCover
public class MediaCoverService :
IHandleAsync<ArtistRefreshCompleteEvent>,
IHandleAsync<ArtistDeletedEvent>,
IHandleAsync<AlbumDeletedEvent>,
IMapCoversToLocal
{
private readonly IImageResizer _resizer;
@ -292,5 +293,13 @@ namespace NzbDrone.Core.MediaCover
}
}
public void HandleAsync(AlbumDeletedEvent message)
{
var path = GetAlbumCoverPath(message.Album.Id);
if (_diskProvider.FolderExists(path))
{
_diskProvider.DeleteFolder(path, true);
}
}
}
}

View File

@ -22,6 +22,7 @@ namespace NzbDrone.Core.MediaFiles
public class MediaFileDeletionService : IDeleteMediaFiles,
IHandleAsync<ArtistDeletedEvent>,
IHandleAsync<AlbumDeletedEvent>,
IHandle<TrackFileDeletedEvent>
{
private readonly IDiskProvider _diskProvider;
@ -128,6 +129,18 @@ namespace NzbDrone.Core.MediaFiles
}
}
public void HandleAsync(AlbumDeletedEvent message)
{
if (message.DeleteFiles)
{
var files = _mediaFileService.GetFilesByAlbum(message.Album.Id);
foreach (var file in files)
{
_recycleBinProvider.DeleteFile(file.Path);
}
}
}
[EventHandleOrder(EventHandleOrder.Last)]
public void Handle(TrackFileDeletedEvent message)
{

View File

@ -17,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles
List<TrackFile> GetFilesWithBasePath(string path);
TrackFile GetFileWithPath(string path);
void DeleteFilesByAlbum(int albumId);
void UnlinkFilesByAlbum(int albumId);
}
@ -70,6 +71,13 @@ namespace NzbDrone.Core.MediaFiles
DeleteMany(ids);
}
public void UnlinkFilesByAlbum(int albumId)
{
var files = DataMapper.Query<TrackFile>().Where(x => x.AlbumId == albumId).ToList();
files.ForEach(x => x.AlbumId = 0);
SetFields(files, f => f.AlbumId);
}
public List<TrackFile> GetFilesByRelease(int releaseId)
{
return Query

View File

@ -160,7 +160,14 @@ namespace NzbDrone.Core.MediaFiles
public void HandleAsync(AlbumDeletedEvent message)
{
_mediaFileRepository.DeleteFilesByAlbum(message.Album.Id);
if (message.DeleteFiles)
{
_mediaFileRepository.DeleteFilesByAlbum(message.Album.Id);
}
else
{
_mediaFileRepository.UnlinkFilesByAlbum(message.Album.Id);
}
}
public List<TrackFile> GetFilesByArtist(int artistId)

View File

@ -0,0 +1,10 @@
using System;
using System.Collections.Generic;
namespace NzbDrone.Core.MetadataSource
{
public interface ISearchForNewEntity
{
List<Object> SearchForNewEntity(string title);
}
}

View File

@ -0,0 +1,10 @@
namespace NzbDrone.Core.MetadataSource.SkyHook.Resource
{
public class EntityResource
{
public int Score { get; set; }
public ArtistResource Artist { get; set; }
public AlbumResource Album { get; set; }
}
}

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using NLog;
using NzbDrone.Common.Cloud;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Http;
using NzbDrone.Core.Exceptions;
@ -15,7 +14,7 @@ using NzbDrone.Core.Profiles.Metadata;
namespace NzbDrone.Core.MetadataSource.SkyHook
{
public class SkyHookProxy : IProvideArtistInfo, ISearchForNewArtist, IProvideAlbumInfo, ISearchForNewAlbum
public class SkyHookProxy : IProvideArtistInfo, ISearchForNewArtist, IProvideAlbumInfo, ISearchForNewAlbum, ISearchForNewEntity
{
private readonly IHttpClient _httpClient;
private readonly Logger _logger;
@ -247,6 +246,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
.AddQueryParam("type", "album")
.AddQueryParam("query", title.ToLower().Trim())
.AddQueryParam("artist", artist.IsNotNullOrWhiteSpace() ? artist.ToLower().Trim() : string.Empty)
.AddQueryParam("includeTracks", "1")
.Build();
@ -266,6 +266,31 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
}
}
public List<Object> SearchForNewEntity(string title)
{
try
{
var httpRequest = _requestBuilder.GetRequestBuilder().Create()
.SetSegment("route", "search")
.AddQueryParam("type", "all")
.AddQueryParam("query", title.ToLower().Trim())
.Build();
var httpResponse = _httpClient.Get<List<EntityResource>>(httpRequest);
return httpResponse.Resource.SelectList(MapSearchResult);
}
catch (HttpException)
{
throw new SkyHookException("Search for '{0}' failed. Unable to communicate with LidarrAPI.", title);
}
catch (Exception ex)
{
_logger.Warn(ex, ex.Message);
throw new SkyHookException("Search for '{0}' failed. Invalid response received from LidarrAPI.", title);
}
}
private Artist MapSearchResult(ArtistResource resource)
{
var artist = _artistService.FindById(resource.Id);
@ -280,20 +305,34 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
private Album MapSearchResult(AlbumResource resource)
{
var album = _albumService.FindById(resource.Id) ?? MapAlbum(resource, null);
var artists = resource.Artists.Select(MapArtistMetadata).ToDictionary(x => x.ForeignArtistId, x => x);
var artist = _artistService.FindById(resource.ArtistId);
if (artist == null)
{
artist = new Artist();
artist.Metadata = MapArtistMetadata(resource.Artists.Single(x => x.Id == resource.ArtistId));
artist.Metadata = artists[resource.ArtistId];
}
var album = _albumService.FindById(resource.Id) ?? MapAlbum(resource, artists);
album.Artist = artist;
album.ArtistMetadata = artist.Metadata;
album.ArtistMetadata = artist.Metadata.Value;
return album;
}
private Object MapSearchResult(EntityResource resource)
{
if (resource.Artist != null)
{
return MapSearchResult(resource.Artist);
}
else
{
return MapSearchResult(resource.Album);
}
}
private static Album MapAlbum(AlbumResource resource, Dictionary<string, ArtistMetadata> artistDict)
{
Album album = new Album();

View File

@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music
{
public static class ArtistNameNormalizer
{
private readonly static Dictionary<string, string> PreComputedTitles = new Dictionary<string, string>
{
{ "281588", "a to z" },
{ "266757", "ad trials triumph early church" },
{ "289260", "ad bible continues"}
};
public static string Normalize(string title, string mbID)
{
if (PreComputedTitles.ContainsKey(mbID))
{
return PreComputedTitles[mbID];
}
return Parser.Parser.NormalizeTitle(title).ToLower();
}
}
}

View File

@ -1,8 +1,4 @@
using NzbDrone.Common.Messaging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music.Events
{

View File

@ -10,11 +10,13 @@ namespace NzbDrone.Core.Music.Events
{
public Album Album { get; private set; }
public bool DeleteFiles { get; private set; }
public bool AddImportListExclusion { get; private set; }
public AlbumDeletedEvent(Album album, bool deleteFiles)
public AlbumDeletedEvent(Album album, bool deleteFiles, bool addImportListExclusion)
{
Album = album;
DeleteFiles = deleteFiles;
AddImportListExclusion = addImportListExclusion;
}
}
}

View File

@ -0,0 +1,14 @@
using NzbDrone.Common.Messaging;
namespace NzbDrone.Core.Music.Events
{
public class AlbumUpdatedEvent : IEvent
{
public Album Album { get; private set; }
public AlbumUpdatedEvent(Album album)
{
Album = album;
}
}
}

View File

@ -1,18 +1,16 @@
using NzbDrone.Common.Messaging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music.Events
{
public class ArtistAddedEvent : IEvent
{
public Artist Artist { get; private set; }
public bool DoRefresh { get; private set; }
public ArtistAddedEvent(Artist artist)
public ArtistAddedEvent(Artist artist, bool doRefresh = true)
{
Artist = artist;
DoRefresh = doRefresh;
}
}
}

View File

@ -0,0 +1,22 @@
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music.Commands;
using NzbDrone.Core.Music.Events;
namespace NzbDrone.Core.Music
{
public class AlbumAddedHandler : IHandle<AlbumAddedEvent>
{
private readonly IManageCommandQueue _commandQueueManager;
public AlbumAddedHandler(IManageCommandQueue commandQueueManager)
{
_commandQueueManager = commandQueueManager;
}
public void Handle(AlbumAddedEvent message)
{
_commandQueueManager.Push(new RefreshArtistCommand(message.Album.Artist.Value.Id));
}
}
}

View File

@ -18,7 +18,10 @@ namespace NzbDrone.Core.Music
public void Handle(ArtistAddedEvent message)
{
_commandQueueManager.Push(new RefreshArtistCommand(message.Artist.Id, true));
if (message.DoRefresh)
{
_commandQueueManager.Push(new RefreshArtistCommand(message.Artist.Id, true));
}
}
public void Handle(ArtistsImportedEvent message)

View File

@ -12,41 +12,40 @@ namespace NzbDrone.Core.Music
private readonly IAlbumMonitoredService _albumMonitoredService;
private readonly IArtistService _artistService;
private readonly IManageCommandQueue _commandQueueManager;
//private readonly IEpisodeAddedService _episodeAddedService;
private readonly IAlbumAddedService _albumAddedService;
private readonly Logger _logger;
public ArtistScannedHandler(IAlbumMonitoredService albumMonitoredService,
IArtistService artistService,
IManageCommandQueue commandQueueManager,
//IEpisodeAddedService episodeAddedService,
IAlbumAddedService albumAddedService,
Logger logger)
{
_albumMonitoredService = albumMonitoredService;
_artistService = artistService;
_commandQueueManager = commandQueueManager;
//_episodeAddedService = episodeAddedService;
_albumAddedService = albumAddedService;
_logger = logger;
}
private void HandleScanEvents(Artist artist)
{
if (artist.AddOptions == null)
if (artist.AddOptions != null)
{
//_episodeAddedService.SearchForRecentlyAdded(series.Id);
return;
_logger.Info("[{0}] was recently added, performing post-add actions", artist.Name);
_albumMonitoredService.SetAlbumMonitoredStatus(artist, artist.AddOptions);
if (artist.AddOptions.SearchForMissingAlbums)
{
_commandQueueManager.Push(new MissingAlbumSearchCommand(artist.Id));
}
artist.AddOptions = null;
_artistService.RemoveAddOptions(artist);
}
_logger.Info("[{0}] was recently added, performing post-add actions", artist.Name);
_albumMonitoredService.SetAlbumMonitoredStatus(artist, artist.AddOptions);
if (artist.AddOptions.SearchForMissingAlbums)
{
_commandQueueManager.Push(new MissingAlbumSearchCommand(artist.Id));
}
artist.AddOptions = null;
_artistService.RemoveAddOptions(artist);
_albumAddedService.SearchForRecentlyAdded(artist.Id);
}
public void Handle(ArtistScannedEvent message)

View File

@ -0,0 +1,22 @@
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Music
{
public class AddAlbumOptions : IEmbeddedDocument
{
public AddAlbumOptions()
{
// default in case not set in db
AddType = AlbumAddType.Automatic;
}
public AlbumAddType AddType { get; set; }
public bool SearchForNewAlbum { get; set; }
}
public enum AlbumAddType
{
Automatic,
Manual
}
}

View File

@ -1,8 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Core.Music
{
public class AddArtistOptions : MonitoringOptions

View File

@ -12,14 +12,14 @@ namespace NzbDrone.Core.Music
public Album()
{
OldForeignAlbumIds = new List<string>();
Overview = string.Empty;
Images = new List<MediaCover.MediaCover>();
Links = new List<Links>();
Genres = new List<string>();
SecondaryTypes = new List<SecondaryAlbumType>();
Ratings = new Ratings();
Artist = new Artist();
AddOptions = new AddAlbumOptions();
}
// These correspond to columns in the Albums table
@ -34,7 +34,7 @@ namespace NzbDrone.Core.Music
public List<MediaCover.MediaCover> Images { get; set; }
public List<Links> Links { get; set; }
public List<string> Genres { get; set; }
public String AlbumType { get; set; }
public string AlbumType { get; set; }
public List<SecondaryAlbumType> SecondaryTypes { get; set; }
public Ratings Ratings { get; set; }
@ -46,7 +46,7 @@ namespace NzbDrone.Core.Music
public DateTime? LastInfoSync { get; set; }
public DateTime Added { get; set; }
[MemberwiseEqualityIgnore]
public AddArtistOptions AddOptions { get; set; }
public AddAlbumOptions AddOptions { get; set; }
// These are dynamically queried from other tables
[MemberwiseEqualityIgnore]
@ -94,13 +94,13 @@ namespace NzbDrone.Core.Music
AddOptions = other.AddOptions;
}
public override void ApplyChanges(Album otherAlbum)
public override void ApplyChanges(Album other)
{
ForeignAlbumId = otherAlbum.ForeignAlbumId;
ProfileId = otherAlbum.ProfileId;
AddOptions = otherAlbum.AddOptions;
Monitored = otherAlbum.Monitored;
AnyReleaseOk = otherAlbum.AnyReleaseOk;
ForeignAlbumId = other.ForeignAlbumId;
ProfileId = other.ProfileId;
AddOptions = other.AddOptions;
Monitored = other.Monitored;
AnyReleaseOk = other.AnyReleaseOk;
}
}
}

View File

@ -75,22 +75,20 @@ namespace NzbDrone.Core.Music
AddOptions = other.AddOptions;
}
public override void ApplyChanges(Artist otherArtist)
public override void ApplyChanges(Artist other)
{
Path = other.Path;
QualityProfileId = other.QualityProfileId;
QualityProfile = other.QualityProfile;
MetadataProfileId = other.MetadataProfileId;
MetadataProfile = other.MetadataProfile;
Path = otherArtist.Path;
QualityProfileId = otherArtist.QualityProfileId;
QualityProfile = otherArtist.QualityProfile;
MetadataProfileId = otherArtist.MetadataProfileId;
MetadataProfile = otherArtist.MetadataProfile;
Albums = otherArtist.Albums;
Tags = otherArtist.Tags;
AddOptions = otherArtist.AddOptions;
RootFolderPath = otherArtist.RootFolderPath;
Monitored = otherArtist.Monitored;
AlbumFolder = otherArtist.AlbumFolder;
Albums = other.Albums;
Tags = other.Tags;
AddOptions = other.AddOptions;
RootFolderPath = other.RootFolderPath;
Monitored = other.Monitored;
AlbumFolder = other.AlbumFolder;
}
}
}

View File

@ -1,6 +1,5 @@
using System;
using System.Linq;
using NLog;
using Marr.Data.QGen;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Datastore.Extensions;
@ -17,9 +16,9 @@ namespace NzbDrone.Core.Music
List<Album> GetLastAlbums(IEnumerable<int> artistMetadataIds);
List<Album> GetNextAlbums(IEnumerable<int> artistMetadataIds);
List<Album> GetAlbumsByArtistMetadataId(int artistMetadataId);
List<Album> GetAlbumsForRefresh(int artistId, IEnumerable<string> foreignIds);
List<Album> GetAlbumsForRefresh(int artistMetadataId, IEnumerable<string> foreignIds);
Album FindByTitle(int artistMetadataId, string title);
Album FindById(string foreignId);
Album FindById(string foreignAlbumId);
PagingSpec<Album> AlbumsWithoutFiles(PagingSpec<Album> pagingSpec);
PagingSpec<Album> AlbumsWhereCutoffUnmet(PagingSpec<Album> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff);
List<Album> AlbumsBetweenDates(DateTime startDate, DateTime endDate, bool includeUnmonitored);
@ -34,13 +33,11 @@ namespace NzbDrone.Core.Music
public class AlbumRepository : BasicRepository<Album>, IAlbumRepository
{
private readonly IMainDatabase _database;
private readonly Logger _logger;
public AlbumRepository(IMainDatabase database, IEventAggregator eventAggregator, Logger logger)
public AlbumRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
_database = database;
_logger = logger;
}
public List<Album> GetAlbums(int artistId)

View File

@ -9,7 +9,7 @@ namespace NzbDrone.Core.Music
public interface IArtistMetadataRepository : IBasicRepository<ArtistMetadata>
{
List<ArtistMetadata> FindById(List<string> foreignIds);
bool UpsertMany(List<ArtistMetadata> artists);
bool UpsertMany(List<ArtistMetadata> data);
}
public class ArtistMetadataRepository : BasicRepository<ArtistMetadata>, IArtistMetadataRepository

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