New: Don't require artist mapping

pull/1689/head
ta264 5 years ago committed by Qstick
parent 1cc434a498
commit be4e748977

@ -2,7 +2,7 @@
# editorconfig.org
root = true
[*.{cs}]
[*.cs]
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

@ -244,9 +244,7 @@ function HistoryDetails(props) {
if (eventType === 'trackFileRenamed') {
const {
sourcePath,
sourceRelativePath,
path,
relativePath
path
} = data;
return (
@ -256,20 +254,10 @@ function HistoryDetails(props) {
data={sourcePath}
/>
<DescriptionListItem
title="Source Relative Path"
data={sourceRelativePath}
/>
<DescriptionListItem
title="Destination Path"
data={path}
/>
<DescriptionListItem
title="Destination Relative Path"
data={relativePath}
/>
</DescriptionList>
);
}

@ -3,7 +3,7 @@ import React from 'react';
function ArtistMetadataProfilePopoverContent() {
return (
<div>
Select 'None' to only include items manually added via search
Select 'None' to only include items manually added via search or that match files on disk
</div>
);
}

@ -1,166 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import ImportArtistTableConnector from './ImportArtistTableConnector';
import ImportArtistFooterConnector from './ImportArtistFooterConnector';
class ImportArtist extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
scroller: null
};
}
//
// Control
setScrollerRef = (ref) => {
this.setState({ scroller: ref });
}
//
// Listeners
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState, { parseIds: false });
}
onSelectAllChange = ({ value }) => {
// Only select non-dupes
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onRemoveSelectedStateItem = (id) => {
this.setState((state) => {
const selectedState = Object.assign({}, state.selectedState);
delete selectedState[id];
return {
...state,
selectedState
};
});
}
onInputChange = ({ name, value }) => {
this.props.onInputChange(this.getSelectedIds(), name, value);
}
onImportPress = () => {
this.props.onImportPress(this.getSelectedIds());
}
//
// Render
render() {
const {
rootFolderId,
path,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
unmappedFolders,
showMetadataProfile
} = this.props;
const {
allSelected,
allUnselected,
selectedState,
scroller
} = this.state;
return (
<PageContent title="Import Artist">
<PageContentBodyConnector
registerScroller={this.setScrollerRef}
onScroll={this.onScroll}
>
{
rootFoldersFetching && !rootFoldersPopulated &&
<LoadingIndicator />
}
{
!rootFoldersFetching && !!rootFoldersError &&
<div>Unable to load root folders</div>
}
{
!rootFoldersError && rootFoldersPopulated && !unmappedFolders.length &&
<div>
All artist in {path} have been imported
</div>
}
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length && scroller &&
<ImportArtistTableConnector
rootFolderId={rootFolderId}
unmappedFolders={unmappedFolders}
allSelected={allSelected}
allUnselected={allUnselected}
selectedState={selectedState}
scroller={scroller}
showMetadataProfile={showMetadataProfile}
onSelectAllChange={this.onSelectAllChange}
onSelectedChange={this.onSelectedChange}
onRemoveSelectedStateItem={this.onRemoveSelectedStateItem}
/>
}
</PageContentBodyConnector>
{
!rootFoldersError && rootFoldersPopulated && !!unmappedFolders.length &&
<ImportArtistFooterConnector
selectedIds={this.getSelectedIds()}
showMetadataProfile={showMetadataProfile}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
}
</PageContent>
);
}
}
ImportArtist.propTypes = {
rootFolderId: PropTypes.number.isRequired,
path: PropTypes.string,
rootFoldersFetching: PropTypes.bool.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
rootFoldersError: PropTypes.object,
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
items: PropTypes.arrayOf(PropTypes.object),
showMetadataProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired
};
ImportArtist.defaultProps = {
unmappedFolders: []
};
export default ImportArtist;

@ -1,170 +0,0 @@
/* eslint max-params: 0 */
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportArtistValue, importArtist, clearImportArtist } from 'Store/Actions/importArtistActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { setAddDefault } from 'Store/Actions/searchActions';
import createRouteMatchShape from 'Helpers/Props/Shapes/createRouteMatchShape';
import ImportArtist from './ImportArtist';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.rootFolders,
(state) => state.addArtist,
(state) => state.importArtist,
(state) => state.settings.qualityProfiles,
(state) => state.settings.metadataProfiles,
(
match,
rootFolders,
addArtist,
importArtistState,
qualityProfiles,
metadataProfiles
) => {
const {
isFetching: rootFoldersFetching,
isPopulated: rootFoldersPopulated,
error: rootFoldersError,
items
} = rootFolders;
const rootFolderId = parseInt(match.params.rootFolderId);
const result = {
rootFolderId,
rootFoldersFetching,
rootFoldersPopulated,
rootFoldersError,
qualityProfiles: qualityProfiles.items,
metadataProfiles: metadataProfiles.items,
showMetadataProfile: metadataProfiles.items.length > 1,
defaultQualityProfileId: addArtist.defaults.qualityProfileId,
defaultMetadataProfileId: addArtist.defaults.metadataProfileId
};
if (items.length) {
const rootFolder = _.find(items, { id: rootFolderId });
return {
...result,
...rootFolder,
items: importArtistState.items
};
}
return result;
}
);
}
const mapDispatchToProps = {
dispatchSetImportArtistValue: setImportArtistValue,
dispatchImportArtist: importArtist,
dispatchClearImportArtist: clearImportArtist,
dispatchFetchRootFolders: fetchRootFolders,
dispatchSetAddDefault: setAddDefault
};
class ImportArtistConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
qualityProfiles,
metadataProfiles,
defaultQualityProfileId,
defaultMetadataProfileId,
dispatchFetchRootFolders,
dispatchSetAddDefault
} = this.props;
if (!this.props.rootFoldersPopulated) {
dispatchFetchRootFolders();
}
let setDefaults = false;
const setDefaultPayload = {};
if (
!defaultQualityProfileId ||
!qualityProfiles.some((p) => p.id === defaultQualityProfileId)
) {
setDefaults = true;
setDefaultPayload.qualityProfileId = qualityProfiles[0].id;
}
if (
!defaultMetadataProfileId ||
!metadataProfiles.some((p) => p.id === defaultMetadataProfileId)
) {
setDefaults = true;
setDefaultPayload.metadataProfileId = metadataProfiles[0].id;
}
if (setDefaults) {
dispatchSetAddDefault(setDefaultPayload);
}
}
componentWillUnmount() {
this.props.dispatchClearImportArtist();
}
//
// Listeners
onInputChange = (ids, name, value) => {
this.props.dispatchSetAddDefault({ [name]: value });
ids.forEach((id) => {
this.props.dispatchSetImportArtistValue({
id,
[name]: value
});
});
}
onImportPress = (ids) => {
this.props.dispatchImportArtist({ ids });
}
//
// Render
render() {
return (
<ImportArtist
{...this.props}
onInputChange={this.onInputChange}
onImportPress={this.onImportPress}
/>
);
}
}
const routeMatchShape = createRouteMatchShape({
rootFolderId: PropTypes.string.isRequired
});
ImportArtistConnector.propTypes = {
match: routeMatchShape.isRequired,
rootFoldersPopulated: PropTypes.bool.isRequired,
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
defaultQualityProfileId: PropTypes.number.isRequired,
defaultMetadataProfileId: PropTypes.number.isRequired,
dispatchSetImportArtistValue: PropTypes.func.isRequired,
dispatchImportArtist: PropTypes.func.isRequired,
dispatchClearImportArtist: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchSetAddDefault: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistConnector);

@ -1,33 +0,0 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.label {
margin-bottom: 3px;
font-weight: bold;
}
.importButtonContainer {
display: flex;
align-items: center;
}
.importButton {
composes: button from '~Components/Link/SpinnerButton.css';
height: 35px;
}
.loadingButton {
composes: importButton;
margin-left: 10px;
}
.loading {
composes: loading from '~Components/Loading/LoadingIndicator.css';
margin: 0 10px 0 12px;
text-align: left;
}

@ -1,261 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { inputTypes, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import CheckInput from 'Components/Form/CheckInput';
import FormInputGroup from 'Components/Form/FormInputGroup';
import PageContentFooter from 'Components/Page/PageContentFooter';
import styles from './ImportArtistFooter.css';
const MIXED = 'mixed';
class ImportArtistFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
defaultMonitor,
defaultQualityProfileId,
defaultMetadataProfileId,
defaultAlbumFolder
} = props;
this.state = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
metadataProfileId: defaultMetadataProfileId,
albumFolder: defaultAlbumFolder
};
}
componentDidUpdate(prevProps, prevState) {
const {
defaultMonitor,
defaultQualityProfileId,
defaultMetadataProfileId,
defaultAlbumFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isMetadataProfileIdMixed,
isAlbumFolderMixed
} = this.props;
const {
monitor,
qualityProfileId,
metadataProfileId,
albumFolder
} = this.state;
const newState = {};
if (isMonitorMixed && monitor !== MIXED) {
newState.monitor = MIXED;
} else if (!isMonitorMixed && monitor !== defaultMonitor) {
newState.monitor = defaultMonitor;
}
if (isQualityProfileIdMixed && qualityProfileId !== MIXED) {
newState.qualityProfileId = MIXED;
} else if (!isQualityProfileIdMixed && qualityProfileId !== defaultQualityProfileId) {
newState.qualityProfileId = defaultQualityProfileId;
}
if (isMetadataProfileIdMixed && metadataProfileId !== MIXED) {
newState.metadataProfileId = MIXED;
} else if (!isMetadataProfileIdMixed && metadataProfileId !== defaultMetadataProfileId) {
newState.metadataProfileId = defaultMetadataProfileId;
}
if (isAlbumFolderMixed && albumFolder != null) {
newState.albumFolder = null;
} else if (!isAlbumFolderMixed && albumFolder !== defaultAlbumFolder) {
newState.albumFolder = defaultAlbumFolder;
}
if (!_.isEmpty(newState)) {
this.setState(newState);
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
this.props.onInputChange({ name, value });
}
//
// Render
render() {
const {
selectedCount,
isImporting,
isLookingUpArtist,
isMonitorMixed,
isQualityProfileIdMixed,
isMetadataProfileIdMixed,
hasUnsearchedItems,
showMetadataProfile,
onImportPress,
onLookupPress,
onCancelLookupPress
} = this.props;
const {
monitor,
qualityProfileId,
metadataProfileId,
albumFolder
} = this.state;
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<div className={styles.label}>
Monitor
</div>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
value={monitor}
isDisabled={!selectedCount}
includeMixed={isMonitorMixed}
onChange={this.onInputChange}
/>
</div>
<div className={styles.inputContainer}>
<div className={styles.label}>
Quality Profile
</div>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
isDisabled={!selectedCount}
includeMixed={isQualityProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
{
showMetadataProfile &&
<div className={styles.inputContainer}>
<div className={styles.label}>
Metadata Profile
</div>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
value={metadataProfileId}
isDisabled={!selectedCount}
includeMixed={isMetadataProfileIdMixed}
onChange={this.onInputChange}
/>
</div>
}
<div className={styles.inputContainer}>
<div className={styles.label}>
Album Folder
</div>
<CheckInput
name="albumFolder"
value={albumFolder}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div>
<div className={styles.label}>
&nbsp;
</div>
<div className={styles.importButtonContainer}>
<SpinnerButton
className={styles.importButton}
kind={kinds.PRIMARY}
isSpinning={isImporting}
isDisabled={!selectedCount || isLookingUpArtist}
onPress={onImportPress}
>
Import {selectedCount} Artist(s)
</SpinnerButton>
{
isLookingUpArtist &&
<Button
className={styles.loadingButton}
kind={kinds.WARNING}
onPress={onCancelLookupPress}
>
Cancel Processing
</Button>
}
{
hasUnsearchedItems &&
<Button
className={styles.loadingButton}
kind={kinds.SUCCESS}
onPress={onLookupPress}
>
Start Processing
</Button>
}
{
isLookingUpArtist &&
<LoadingIndicator
className={styles.loading}
size={24}
/>
}
{
isLookingUpArtist &&
'Processing Folders'
}
</div>
</div>
</PageContentFooter>
);
}
}
ImportArtistFooter.propTypes = {
selectedCount: PropTypes.number.isRequired,
isImporting: PropTypes.bool.isRequired,
isLookingUpArtist: PropTypes.bool.isRequired,
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultMetadataProfileId: PropTypes.number,
defaultAlbumFolder: PropTypes.bool.isRequired,
isMonitorMixed: PropTypes.bool.isRequired,
isQualityProfileIdMixed: PropTypes.bool.isRequired,
isMetadataProfileIdMixed: PropTypes.bool.isRequired,
isAlbumFolderMixed: PropTypes.bool.isRequired,
hasUnsearchedItems: PropTypes.bool.isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onImportPress: PropTypes.func.isRequired,
onLookupPress: PropTypes.func.isRequired,
onCancelLookupPress: PropTypes.func.isRequired
};
export default ImportArtistFooter;

@ -1,61 +0,0 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import ImportArtistFooter from './ImportArtistFooter';
import { lookupUnsearchedArtist, cancelLookupArtist } from 'Store/Actions/importArtistActions';
function isMixed(items, selectedIds, defaultValue, key) {
return _.some(items, (artist) => {
return selectedIds.indexOf(artist.id) > -1 && artist[key] !== defaultValue;
});
}
function createMapStateToProps() {
return createSelector(
(state) => state.addArtist,
(state) => state.importArtist,
(state, { selectedIds }) => selectedIds,
(addArtist, importArtist, selectedIds) => {
const {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
metadataProfileId: defaultMetadataProfileId,
albumFolder: defaultAlbumFolder
} = addArtist.defaults;
const {
isLookingUpArtist,
isImporting,
items
} = importArtist;
const isMonitorMixed = isMixed(items, selectedIds, defaultMonitor, 'monitor');
const isQualityProfileIdMixed = isMixed(items, selectedIds, defaultQualityProfileId, 'qualityProfileId');
const isMetadataProfileIdMixed = isMixed(items, selectedIds, defaultMetadataProfileId, 'metadataProfileId');
const isAlbumFolderMixed = isMixed(items, selectedIds, defaultAlbumFolder, 'albumFolder');
const hasUnsearchedItems = !isLookingUpArtist && items.some((item) => !item.isPopulated);
return {
selectedCount: selectedIds.length,
isLookingUpArtist,
isImporting,
defaultMonitor,
defaultQualityProfileId,
defaultMetadataProfileId,
defaultAlbumFolder,
isMonitorMixed,
isQualityProfileIdMixed,
isMetadataProfileIdMixed,
isAlbumFolderMixed,
hasUnsearchedItems
};
}
);
}
const mapDispatchToProps = {
onLookupPress: lookupUnsearchedArtist,
onCancelLookupPress: cancelLookupArtist
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistFooter);

@ -1,38 +0,0 @@
.folder {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 1 0 200px;
}
.monitor {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.metadataProfile {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.albumFolder {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.artist {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.detailsIcon {
margin-left: 8px;
}

@ -1,96 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
// import SeriesTypePopoverContent from 'AddArtist/SeriesTypePopoverContent';
import styles from './ImportArtistHeader.css';
function ImportArtistHeader(props) {
const {
showMetadataProfile,
allSelected,
allUnselected,
onSelectAllChange
} = props;
return (
<VirtualTableHeader>
<VirtualTableSelectAllHeaderCell
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
<VirtualTableHeaderCell
className={styles.folder}
name="folder"
>
Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.monitor}
name="monitor"
>
Monitor
<Popover
anchor={
<Icon
className={styles.detailsIcon}
name={icons.INFO}
/>
}
title="Monitoring Options"
body={<ArtistMonitoringOptionsPopoverContent />}
position={tooltipPositions.RIGHT}
/>
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.qualityProfile}
name="qualityProfileId"
>
Quality Profile
</VirtualTableHeaderCell>
{
showMetadataProfile &&
<VirtualTableHeaderCell
className={styles.metadataProfile}
name="metadataProfileId"
>
Metadata Profile
</VirtualTableHeaderCell>
}
<VirtualTableHeaderCell
className={styles.albumFolder}
name="albumFolder"
>
Album Folder
</VirtualTableHeaderCell>
<VirtualTableHeaderCell
className={styles.artist}
name="artist"
>
Artist
</VirtualTableHeaderCell>
</VirtualTableHeader>
);
}
ImportArtistHeader.propTypes = {
showMetadataProfile: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default ImportArtistHeader;

@ -1,45 +0,0 @@
.selectInput {
composes: input from '~Components/Form/CheckInput.css';
}
.folder {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 1 0 200px;
line-height: 36px;
}
.monitor {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 200px;
min-width: 185px;
}
.qualityProfile,
.metadataProfile {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 250px;
min-width: 170px;
}
.albumFolder {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 150px;
min-width: 120px;
}
.artist {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
flex: 0 1 400px;
min-width: 300px;
}
.hideMetadataProfile {
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
display: none;
}

@ -1,106 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { inputTypes } from 'Helpers/Props';
import FormInputGroup from 'Components/Form/FormInputGroup';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import ImportArtistSelectArtistConnector from './SelectArtist/ImportArtistSelectArtistConnector';
import styles from './ImportArtistRow.css';
function ImportArtistRow(props) {
const {
id,
monitor,
qualityProfileId,
metadataProfileId,
albumFolder,
selectedArtist,
isExistingArtist,
showMetadataProfile,
isSelected,
onSelectedChange,
onInputChange
} = props;
return (
<>
<VirtualTableSelectCell
inputClassName={styles.selectInput}
id={id}
isSelected={isSelected}
isDisabled={!selectedArtist || isExistingArtist}
onSelectedChange={onSelectedChange}
/>
<VirtualTableRowCell className={styles.folder}>
{id}
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.monitor}>
<FormInputGroup
type={inputTypes.MONITOR_ALBUMS_SELECT}
name="monitor"
value={monitor}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.qualityProfile}>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="qualityProfileId"
value={qualityProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell
className={showMetadataProfile ? styles.metadataProfile : styles.hideMetadataProfile}
>
<FormInputGroup
type={inputTypes.METADATA_PROFILE_SELECT}
name="metadataProfileId"
value={metadataProfileId}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.albumFolder}>
<FormInputGroup
type={inputTypes.CHECK}
name="albumFolder"
value={albumFolder}
onChange={onInputChange}
/>
</VirtualTableRowCell>
<VirtualTableRowCell className={styles.artist}>
<ImportArtistSelectArtistConnector
id={id}
isExistingArtist={isExistingArtist}
/>
</VirtualTableRowCell>
</>
);
}
ImportArtistRow.propTypes = {
id: PropTypes.string.isRequired,
monitor: PropTypes.string.isRequired,
qualityProfileId: PropTypes.number.isRequired,
metadataProfileId: PropTypes.number.isRequired,
albumFolder: PropTypes.bool.isRequired,
selectedArtist: PropTypes.object,
isExistingArtist: PropTypes.bool.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired
};
ImportArtistRow.defaultsProps = {
items: []
};
export default ImportArtistRow;

@ -1,87 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { setImportArtistValue } from 'Store/Actions/importArtistActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import ImportArtistRow from './ImportArtistRow';
function createImportArtistItemSelector() {
return createSelector(
(state, { id }) => id,
(state) => state.importArtist.items,
(id, items) => {
return _.find(items, { id }) || {};
}
);
}
function createMapStateToProps() {
return createSelector(
createImportArtistItemSelector(),
createAllArtistSelector(),
(item, artist) => {
const selectedArtist = item && item.selectedArtist;
const isExistingArtist = !!selectedArtist && _.some(artist, { foreignArtistId: selectedArtist.foreignArtistId });
return {
...item,
isExistingArtist
};
}
);
}
const mapDispatchToProps = {
setImportArtistValue
};
class ImportArtistRowConnector extends Component {
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setImportArtistValue({
id: this.props.id,
[name]: value
});
}
//
// Render
render() {
// Don't show the row until we have the information we require for it.
const {
items,
monitor,
albumFolder
} = this.props;
if (!items || !monitor || !albumFolder == null) {
return null;
}
return (
<ImportArtistRow
{...this.props}
onInputChange={this.onInputChange}
onArtistSelect={this.onArtistSelect}
/>
);
}
}
ImportArtistRowConnector.propTypes = {
rootFolderId: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
monitor: PropTypes.string,
albumFolder: PropTypes.bool,
items: PropTypes.arrayOf(PropTypes.object),
setImportArtistValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistRowConnector);

@ -1,3 +0,0 @@
.input {
composes: input from '~Components/Form/CheckInput.css';
}

@ -1,195 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import VirtualTable from 'Components/Table/VirtualTable';
import VirtualTableRow from 'Components/Table/VirtualTableRow';
import ImportArtistHeader from './ImportArtistHeader';
import ImportArtistRowConnector from './ImportArtistRowConnector';
class ImportArtistTable extends Component {
//
// Lifecycle
componentDidMount() {
const {
unmappedFolders,
defaultMonitor,
defaultQualityProfileId,
defaultMetadataProfileId,
defaultAlbumFolder,
onArtistLookup,
onSetImportArtistValue
} = this.props;
const values = {
monitor: defaultMonitor,
qualityProfileId: defaultQualityProfileId,
metadataProfileId: defaultMetadataProfileId,
albumFolder: defaultAlbumFolder
};
unmappedFolders.forEach((unmappedFolder) => {
const id = unmappedFolder.name;
onArtistLookup(id, unmappedFolder.path);
onSetImportArtistValue({
id,
...values
});
});
}
// This isn't great, but it's the most reliable way to ensure the items
// are checked off even if they aren't actually visible since the cells
// are virtualized.
componentDidUpdate(prevProps) {
const {
items,
selectedState,
onSelectedChange,
onRemoveSelectedStateItem
} = this.props;
prevProps.items.forEach((prevItem) => {
const {
id
} = prevItem;
const item = _.find(items, { id });
if (!item) {
onRemoveSelectedStateItem(id);
return;
}
const selectedArtist = item.selectedArtist;
const isSelected = selectedState[id];
const isExistingArtist = !!selectedArtist &&
_.some(prevProps.allArtists, { foreignArtistId: selectedArtist.foreignArtistId });
// Props doesn't have a selected artist or
// the selected artist is an existing artist.
if ((!selectedArtist && prevItem.selectedArtist) || (isExistingArtist && !prevItem.selectedArtist)) {
onSelectedChange({ id, value: false });
return;
}
// State is selected, but a artist isn't selected or
// the selected artist is an existing artist.
if (isSelected && (!selectedArtist || isExistingArtist)) {
onSelectedChange({ id, value: false });
return;
}
// A artist is being selected that wasn't previously selected.
if (selectedArtist && selectedArtist !== prevItem.selectedArtist) {
onSelectedChange({ id, value: true });
return;
}
});
}
//
// Control
rowRenderer = ({ key, rowIndex, style }) => {
const {
rootFolderId,
items,
selectedState,
showMetadataProfile,
onSelectedChange
} = this.props;
const item = items[rowIndex];
return (
<VirtualTableRow
key={key}
style={style}
>
<ImportArtistRowConnector
key={item.id}
style={style}
rootFolderId={rootFolderId}
showMetadataProfile={showMetadataProfile}
isSelected={selectedState[item.id]}
onSelectedChange={onSelectedChange}
id={item.id}
/>
</VirtualTableRow>
);
}
//
// Render
render() {
const {
items,
allSelected,
allUnselected,
isSmallScreen,
showMetadataProfile,
scroller,
selectedState,
onSelectAllChange
} = this.props;
if (!items.length) {
return null;
}
return (
<VirtualTable
items={items}
isSmallScreen={isSmallScreen}
scroller={scroller}
rowHeight={52}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<ImportArtistHeader
showMetadataProfile={showMetadataProfile}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
selectedState={selectedState}
/>
);
}
}
ImportArtistTable.propTypes = {
rootFolderId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
unmappedFolders: PropTypes.arrayOf(PropTypes.object),
defaultMonitor: PropTypes.string.isRequired,
defaultQualityProfileId: PropTypes.number,
defaultMetadataProfileId: PropTypes.number,
defaultAlbumFolder: PropTypes.bool.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
allArtists: PropTypes.arrayOf(PropTypes.object),
scroller: PropTypes.instanceOf(Element).isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
scrollTop: PropTypes.number.isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onRemoveSelectedStateItem: PropTypes.func.isRequired,
onArtistLookup: PropTypes.func.isRequired,
onSetImportArtistValue: PropTypes.func.isRequired
};
export default ImportArtistTable;

@ -1,43 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector';
import ImportArtistTable from './ImportArtistTable';
function createMapStateToProps() {
return createSelector(
(state) => state.addArtist,
(state) => state.importArtist,
(state) => state.app.dimensions,
createAllArtistSelector(),
(addArtist, importArtist, dimensions, allArtists) => {
return {
defaultMonitor: addArtist.defaults.monitor,
defaultQualityProfileId: addArtist.defaults.qualityProfileId,
defaultMetadataProfileId: addArtist.defaults.metadataProfileId,
defaultAlbumFolder: addArtist.defaults.albumFolder,
items: importArtist.items,
isSmallScreen: dimensions.isSmallScreen,
allArtists
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onArtistLookup(name, path) {
dispatch(queueLookupArtist({
name,
path,
term: name
}));
},
onSetImportArtistValue(values) {
dispatch(setImportArtistValue(values));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(ImportArtistTable);

@ -1,19 +0,0 @@
.artistNameContainer {
display: flex;
align-items: center;
flex: 0 1 auto;
overflow: hidden;
}
.artistName {
@add-mixin truncate;
}
.disambiguation {
margin-right: 5px;
color: $disabledColor;
}
.existing {
margin-left: 5px;
}

@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import styles from './ImportArtistName.css';
function ImportArtistName(props) {
const {
artistName,
disambiguation,
isExistingArtist
} = props;
return (
<div className={styles.artistNameContainer}>
<div className={styles.artistName}>
{artistName}
</div>
<div className={styles.disambiguation}>
{disambiguation}
</div>
{
isExistingArtist &&
<Label
kind={kinds.WARNING}
>
Existing
</Label>
}
</div>
);
}
ImportArtistName.propTypes = {
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
isExistingArtist: PropTypes.bool.isRequired
};
export default ImportArtistName;

@ -1,8 +0,0 @@
.artist {
padding: 10px 20px;
width: 100%;
&:hover {
background-color: $menuItemHoverBackgroundColor;
}
}

@ -1,52 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import ImportArtistName from './ImportArtistName';
import styles from './ImportArtistSearchResult.css';
class ImportArtistSearchResult extends Component {
//
// Listeners
onPress = () => {
this.props.onPress(this.props.foreignArtistId);
}
//
// Render
render() {
const {
artistName,
disambiguation,
// year,
isExistingArtist
} = this.props;
return (
<Link
className={styles.artist}
onPress={this.onPress}
>
<ImportArtistName
artistName={artistName}
disambiguation={disambiguation}
// year={year}
isExistingArtist={isExistingArtist}
/>
</Link>
);
}
}
ImportArtistSearchResult.propTypes = {
foreignArtistId: PropTypes.string.isRequired,
artistName: PropTypes.string.isRequired,
disambiguation: PropTypes.string,
// year: PropTypes.number.isRequired,
isExistingArtist: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
export default ImportArtistSearchResult;

@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createExistingArtistSelector from 'Store/Selectors/createExistingArtistSelector';
import ImportArtistSearchResult from './ImportArtistSearchResult';
function createMapStateToProps() {
return createSelector(
createExistingArtistSelector(),
(isExistingArtist) => {
return {
isExistingArtist
};
}
);
}
export default connect(createMapStateToProps)(ImportArtistSearchResult);

@ -1,77 +0,0 @@
.button {
composes: link from '~Components/Link/Link.css';
display: flex;
align-items: center;
padding: 6px 16px;
width: 100%;
height: 35px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
.loading {
display: inline-block;
}
.warningIcon {
margin-right: 8px;
}
.existing {
margin-left: 5px;
}
.dropdownArrowContainer {
flex: 1 0 auto;
margin-left: 5px;
text-align: right;
}
.contentContainer {
z-index: $popperZIndex;
margin-top: 4px;
/* 400px container witdh with 8px padding on each side */
width: 384px;
}
.content {
padding: 4px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
}
.searchContainer {
display: flex;
}
.searchIconContainer {
width: 58px;
border: 1px solid $inputBorderColor;
border-right: none;
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
background-color: #edf1f2;
text-align: center;
line-height: 33px;
}
.searchInput {
composes: input from '~Components/Form/TextInput.css';
border-radius: 0;
}
.results {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
overflow-x: hidden;
overflow-y: scroll;
max-height: 165px;
}

@ -1,303 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import FormInputButton from 'Components/Form/FormInputButton';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import TextInput from 'Components/Form/TextInput';
import ImportArtistSearchResultConnector from './ImportArtistSearchResultConnector';
import ImportArtistName from './ImportArtistName';
import styles from './ImportArtistSelectArtist.css';
class ImportArtistSelectArtist extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._artistLookupTimeout = null;
this._scheduleUpdate = null;
this._buttonId = getUniqueElememtId();
this._contentId = getUniqueElememtId();
this.state = {
term: props.id,
isOpen: false
};
}
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
//
// Control
_addListener() {
window.addEventListener('click', this.onWindowClick);
}
_removeListener() {
window.removeEventListener('click', this.onWindowClick);
}
//
// Listeners
onWindowClick = (event) => {
const button = document.getElementById(this._buttonId);
const content = document.getElementById(this._contentId);
if (!button || !content) {
return;
}
if (
!button.contains(event.target) &&
!content.contains(event.target) &&
this.state.isOpen
) {
this.setState({ isOpen: false });
this._removeListener();
}
}
onPress = () => {
if (this.state.isOpen) {
this._removeListener();
} else {
this._addListener();
}
this.setState({ isOpen: !this.state.isOpen });
}
onSearchInputChange = ({ value }) => {
if (this._artistLookupTimeout) {
clearTimeout(this._artistLookupTimeout);
}
this.setState({ term: value }, () => {
this._artistLookupTimeout = setTimeout(() => {
this.props.onSearchInputChange(value);
}, 200);
});
}
onRefreshPress = () => {
this.props.onSearchInputChange(this.state.term);
}
onArtistSelect = (foreignArtistId) => {
this.setState({ isOpen: false });
this.props.onArtistSelect(foreignArtistId);
}
//
// Render
render() {
const {
selectedArtist,
isExistingArtist,
isFetching,
isPopulated,
error,
items,
isQueued,
isLookingUpArtist
} = this.props;
const errorMessage = error &&
error.responseJSON &&
error.responseJSON.message;
return (
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._buttonId}
>
<Link
ref={ref}
className={styles.button}
component="div"
onPress={this.onPress}
>
{
isLookingUpArtist && isQueued && !isPopulated ?
<LoadingIndicator
className={styles.loading}
size={20}
/> :
null
}
{
isPopulated && selectedArtist && isExistingArtist ?
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/> :
null
}
{
isPopulated && selectedArtist ?
<ImportArtistName
artistName={selectedArtist.artistName}
disambiguation={selectedArtist.disambiguation}
// year={selectedArtist.year}
isExistingArtist={isExistingArtist}
/> :
null
}
{
isPopulated && !selectedArtist ?
<div className={styles.noMatches}>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
No match found!
</div> :
null
}
{
!isFetching && !!error ?
<div>
<Icon
className={styles.warningIcon}
title={errorMessage}
name={icons.WARNING}
kind={kinds.WARNING}
/>
Search failed, please try again later.
</div> :
null
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport'
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._contentId}
className={styles.contentContainer}
style={style}
>
{
this.state.isOpen ?
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportArtistSearchResultConnector
key={item.foreignArtistId}
foreignArtistId={item.foreignArtistId}
artistName={item.artistName}
disambiguation={item.disambiguation}
// year={item.year}
onPress={this.onArtistSelect}
/>
);
})
}
</div>
</div> :
null
}
</div>
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
ImportArtistSelectArtist.propTypes = {
id: PropTypes.string.isRequired,
selectedArtist: PropTypes.object,
isExistingArtist: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
isQueued: PropTypes.bool.isRequired,
isLookingUpArtist: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onArtistSelect: PropTypes.func.isRequired
};
ImportArtistSelectArtist.defaultProps = {
isFetching: true,
isPopulated: false,
items: [],
isQueued: true
};
export default ImportArtistSelectArtist;

@ -1,76 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { queueLookupArtist, setImportArtistValue } from 'Store/Actions/importArtistActions';
import createImportArtistItemSelector from 'Store/Selectors/createImportArtistItemSelector';
import ImportArtistSelectArtist from './ImportArtistSelectArtist';
function createMapStateToProps() {
return createSelector(
(state) => state.importArtist.isLookingUpArtist,
createImportArtistItemSelector(),
(isLookingUpArtist, item) => {
return {
isLookingUpArtist,
...item
};
}
);
}
const mapDispatchToProps = {
queueLookupArtist,
setImportArtistValue
};
class ImportArtistSelectArtistConnector extends Component {
//
// Listeners
onSearchInputChange = (term) => {
this.props.queueLookupArtist({
name: this.props.id,
term,
topOfQueue: true
});
}
onArtistSelect = (foreignArtistId) => {
const {
id,
items
} = this.props;
this.props.setImportArtistValue({
id,
selectedArtist: _.find(items, { foreignArtistId })
});
}
//
// Render
render() {
return (
<ImportArtistSelectArtist
{...this.props}
onSearchInputChange={this.onSearchInputChange}
onArtistSelect={this.onArtistSelect}
/>
);
}
}
ImportArtistSelectArtistConnector.propTypes = {
id: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object),
selectedArtist: PropTypes.object,
isSelected: PropTypes.bool,
queueLookupArtist: PropTypes.func.isRequired,
setImportArtistValue: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectArtistConnector);

@ -1,30 +0,0 @@
import React, { Component } from 'react';
import { Route } from 'react-router-dom';
import Switch from 'Components/Router/Switch';
import ImportArtistSelectFolderConnector from 'AddArtist/ImportArtist/SelectFolder/ImportArtistSelectFolderConnector';
import ImportArtistConnector from 'AddArtist/ImportArtist/Import/ImportArtistConnector';
class ImportArtist extends Component {
//
// Render
render() {
return (
<Switch>
<Route
exact={true}
path="/add/import"
component={ImportArtistSelectFolderConnector}
/>
<Route
path="/add/import/:rootFolderId"
component={ImportArtistConnector}
/>
</Switch>
);
}
}
export default ImportArtist;

@ -1,32 +0,0 @@
.header {
margin-bottom: 40px;
text-align: center;
font-weight: 300;
font-size: 36px;
}
.tips {
font-size: 20px;
}
.tip {
font-size: $defaultFontSize;
}
.code {
font-size: 12px;
font-family: $monoSpaceFontFamily;
}
.recentFolders {
margin-top: 40px;
}
.startImport {
margin-top: 40px;
text-align: center;
}
.importButtonIcon {
margin-right: 8px;
}

@ -1,147 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import RootFolders from 'RootFolder/RootFolders';
import styles from './ImportArtistSelectFolder.css';
class ImportArtistSelectFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false
};
}
//
// Lifecycle
onAddNewRootFolderPress = () => {
this.setState({ isAddNewRootFolderModalOpen: true });
}
onNewRootFolderSelect = ({ value }) => {
this.props.onNewRootFolderSelect(value);
}
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
}
//
// Render
render() {
const {
isWindows,
isFetching,
isPopulated,
error,
items
} = this.props;
return (
<PageContent title="Import Artist">
<PageContentBodyConnector>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to load root folders</div>
}
{
!error && isPopulated &&
<div>
<div className={styles.header}>
Import artist(s) you already have
</div>
<div className={styles.tips}>
Some tips to ensure the import goes smoothly:
<ul>
<li className={styles.tip}>
Point Lidarr to the folder containing all of your music not a specific one. eg. <span className={styles.code}>"{isWindows ? 'C:\\music' : '/music'}"</span> and not <span className={styles.code}>"{isWindows ? 'C:\\music\\sublime' : '/music/sublime'}"</span>
</li>
</ul>
</div>
{
items.length > 0 ?
<div className={styles.recentFolders}>
<FieldSet legend="Root Folders">
<RootFolders
isFetching={isFetching}
isPopulated={isPopulated}
error={error}
items={items}
/>
</FieldSet>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name={icons.DRIVE}
/>
Choose another folder
</Button>
</div> :
<div className={styles.startImport}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name={icons.DRIVE}
/>
Start Import
</Button>
</div>
}
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
}
</PageContentBodyConnector>
</PageContent>
);
}
}
ImportArtistSelectFolder.propTypes = {
isWindows: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired
};
export default ImportArtistSelectFolder;

@ -1,84 +0,0 @@
import _ from 'lodash';
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 createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions';
import ImportArtistSelectFolder from './ImportArtistSelectFolder';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
createSystemStatusSelector(),
(rootFolders, systemStatus) => {
return {
...rootFolders,
isWindows: systemStatus.isWindows
};
}
);
}
const mapDispatchToProps = {
fetchRootFolders,
addRootFolder,
push
};
class ImportArtistSelectFolderConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
const {
items,
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
const newRootFolders = _.differenceBy(items, prevProps.items, (item) => item.id);
if (newRootFolders.length === 1) {
this.props.push(`${window.Lidarr.urlBase}/add/import/${newRootFolders[0].id}`);
}
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.addRootFolder({ path });
}
//
// Render
render() {
return (
<ImportArtistSelectFolder
{...this.props}
onNewRootFolderSelect={this.onNewRootFolderSelect}
/>
);
}
}
ImportArtistSelectFolderConnector.propTypes = {
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
fetchRootFolders: PropTypes.func.isRequired,
addRootFolder: PropTypes.func.isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ImportArtistSelectFolderConnector);

@ -25,7 +25,6 @@ class TrackRow extends Component {
title,
duration,
trackFilePath,
trackFileRelativePath,
columns,
deleteTrackFile
} = this.props;
@ -86,16 +85,6 @@ class TrackRow extends Component {
);
}
if (name === 'relativePath') {
return (
<TableRowCell key={name}>
{
trackFileRelativePath
}
</TableRowCell>
);
}
if (name === 'duration') {
return (
<TableRowCell
@ -170,7 +159,6 @@ TrackRow.propTypes = {
duration: PropTypes.number.isRequired,
isSaving: PropTypes.bool,
trackFilePath: PropTypes.string,
trackFileRelativePath: PropTypes.string,
mediaInfo: PropTypes.object,
columns: PropTypes.arrayOf(PropTypes.object).isRequired
};

@ -10,8 +10,7 @@ function createMapStateToProps() {
createTrackFileSelector(),
(id, trackFile) => {
return {
trackFilePath: trackFile ? trackFile.path : null,
trackFileRelativePath: trackFile ? trackFile.relativePath : null
trackFilePath: trackFile ? trackFile.path : null
};
}
);

@ -6,7 +6,6 @@ import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector';
import AddNewItemConnector from 'Search/AddNewItemConnector';
import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
@ -76,11 +75,6 @@ function AppRoutes(props) {
component={AddNewItemConnector}
/>
<Route
path="/add/import"
component={ImportArtist}
/>
<Route
path="/artisteditor"
component={ArtistEditorConnector}

@ -27,7 +27,7 @@ function ConnectionLostModal(props) {
<ModalBody>
<div>
Lidarr has lost it's connection to the backend and will need to be reloaded to restore functionality.
Lidarr has lost its connection to the backend and will need to be reloaded to restore functionality.
</div>
<div className={styles.automatic}>

@ -13,8 +13,7 @@ function createMapStateToProps() {
return {
foreignArtistId: artist.foreignArtistId,
artistMonitored: artist.monitored,
trackFilePath: trackFile ? trackFile.path : null,
trackFileRelativePath: trackFile ? trackFile.relativePath : null
trackFilePath: trackFile ? trackFile.path : null
};
}
);

@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import { setArtistEditorSort, setArtistEditorFilter, saveArtistEditor } from 'Store/Actions/artistEditorActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchRootFolders } from 'Store/Actions/settingsActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import ArtistEditor from './ArtistEditor';

@ -217,6 +217,7 @@ class ArtistEditorFooter extends Component {
name="metadataProfileId"
value={metadataProfileId}
includeNoChange={true}
includeNone={true}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>

@ -217,7 +217,6 @@ class ArtistIndex extends Component {
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingArtist}
isDisabled={hasNoArtist}
onPress={onRefreshArtistPress}
/>

@ -20,15 +20,15 @@ function NoArtist(props) {
return (
<div>
<div className={styles.message}>
No artist found, to get started you'll want to add a new artist or album or import some existing ones.
No artists found, to get started you'll want to add a new artist or album or add an existing library location (Root Folder) and update.
</div>
<div className={styles.buttonContainer}>
<Button
to="/add/import"
to="/settings/mediamanagement"
kind={kinds.PRIMARY}
>
Import Existing Artist(s)
Add Root Folder
</Button>
</div>

@ -14,6 +14,7 @@ export const MOVE_ARTIST = 'MoveArtist';
export const REFRESH_ARTIST = 'RefreshArtist';
export const RENAME_FILES = 'RenameFiles';
export const RENAME_ARTIST = 'RenameArtist';
export const RESCAN_FOLDERS = 'RescanFolders';
export const RETAG_FILES = 'RetagFiles';
export const RETAG_ARTIST = 'RetagArtist';
export const RESET_API_KEY = 'ResetApiKey';

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import EditRootFolderModalConnector from 'Settings/MediaManagement/RootFolder/EditRootFolderModalConnector';
import EnhancedSelectInput from './EnhancedSelectInput';
import RootFolderSelectInputOption from './RootFolderSelectInputOption';
import RootFolderSelectInputSelectedValue from './RootFolderSelectInputSelectedValue';
@ -14,8 +14,7 @@ class RootFolderSelectInput extends Component {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false,
newRootFolderPath: ''
isAddNewRootFolderModalOpen: false
};
}
@ -52,9 +51,7 @@ class RootFolderSelectInput extends Component {
}
onNewRootFolderSelect = ({ value }) => {
this.setState({ newRootFolderPath: value }, () => {
this.props.onNewRootFolderSelect(value);
});
this.setState({ newRootFolderPath: value });
}
onAddRootFolderModalClose = () => {
@ -66,8 +63,7 @@ class RootFolderSelectInput extends Component {
render() {
const {
includeNoChange,
onNewRootFolderSelect,
value,
...otherProps
} = this.props;
@ -75,17 +71,16 @@ class RootFolderSelectInput extends Component {
<div>
<EnhancedSelectInput
{...otherProps}
value={value || ''}
selectedValueComponent={RootFolderSelectInputSelectedValue}
optionComponent={RootFolderSelectInputOption}
onChange={this.onChange}
/>
<FileBrowserModal
<EditRootFolderModalConnector
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
onRootFolderAdded={this.onNewRootFolderSelect}
/>
</div>
);
@ -94,16 +89,11 @@ class RootFolderSelectInput extends Component {
RootFolderSelectInput.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onNewRootFolderSelect: PropTypes.func.isRequired
};
RootFolderSelectInput.defaultProps = {
includeNoChange: false
onChange: PropTypes.func.isRequired
};
export default RootFolderSelectInput;

@ -2,20 +2,20 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addRootFolder } from 'Store/Actions/rootFolderActions';
import RootFolderSelectInput from './RootFolderSelectInput';
const ADD_NEW_KEY = 'addNew';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
(state) => state.settings.rootFolders,
(state, { includeNoChange }) => includeNoChange,
(rootFolders, includeNoChange) => {
const values = rootFolders.items.map((rootFolder) => {
return {
key: rootFolder.path,
value: rootFolder.path,
name: rootFolder.name,
freeSpace: rootFolder.freeSpace
};
});
@ -23,7 +23,8 @@ function createMapStateToProps() {
if (includeNoChange) {
values.unshift({
key: 'noChange',
value: 'No Change',
value: '',
name: 'No Change',
isDisabled: true
});
}
@ -32,6 +33,7 @@ function createMapStateToProps() {
values.push({
key: '',
value: '',
name: '',
isDisabled: true,
isHidden: true
});
@ -39,7 +41,8 @@ function createMapStateToProps() {
values.push({
key: ADD_NEW_KEY,
value: 'Add a new path'
value: '',
name: 'Add a new path'
});
return {
@ -51,14 +54,6 @@ function createMapStateToProps() {
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchAddRootFolder(path) {
dispatch(addRootFolder({ path }));
}
};
}
class RootFolderSelectInputConnector extends Component {
//
@ -95,19 +90,11 @@ class RootFolderSelectInputConnector extends Component {
}
}
//
// Listeners
onNewRootFolderSelect = (path) => {
this.props.dispatchAddRootFolder(path);
}
//
// Render
render() {
const {
dispatchAddRootFolder,
...otherProps
} = this.props;
@ -125,12 +112,11 @@ RootFolderSelectInputConnector.propTypes = {
value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
includeNoChange: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
dispatchAddRootFolder: PropTypes.func.isRequired
onChange: PropTypes.func.isRequired
};
RootFolderSelectInputConnector.defaultProps = {
includeNoChange: false
};
export default connect(createMapStateToProps, createMapDispatchToProps)(RootFolderSelectInputConnector);
export default connect(createMapStateToProps)(RootFolderSelectInputConnector);

@ -8,11 +8,14 @@ import styles from './RootFolderSelectInputOption.css';
function RootFolderSelectInputOption(props) {
const {
value,
name,
freeSpace,
isMobile,
...otherProps
} = props;
const text = value === '' ? name : `${name} [${value}]`;
return (
<EnhancedSelectInputOption
isMobile={isMobile}
@ -23,7 +26,7 @@ function RootFolderSelectInputOption(props) {
isMobile && styles.isMobile
)}
>
<div>{value}</div>
<div>{text}</div>
{
freeSpace != null &&
@ -37,6 +40,7 @@ function RootFolderSelectInputOption(props) {
}
RootFolderSelectInputOption.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
isMobile: PropTypes.bool.isRequired

@ -6,19 +6,22 @@ import styles from './RootFolderSelectInputSelectedValue.css';
function RootFolderSelectInputSelectedValue(props) {
const {
name,
value,
freeSpace,
includeFreeSpace,
...otherProps
} = props;
const text = value === '' ? name : `${name} [${value}]`;
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.path}>
{value}
{text}
</div>
{
@ -32,6 +35,7 @@ function RootFolderSelectInputSelectedValue(props) {
}
RootFolderSelectInputSelectedValue.propTypes = {
name: PropTypes.string,
value: PropTypes.string,
freeSpace: PropTypes.number,
includeFreeSpace: PropTypes.bool.isRequired

@ -75,7 +75,7 @@ SelectInput.propTypes = {
className: PropTypes.string,
disabledClassName: PropTypes.string,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
values: PropTypes.arrayOf(PropTypes.object).isRequired,
isDisabled: PropTypes.bool,
hasError: PropTypes.bool,

@ -17,6 +17,8 @@ function getIconName(name) {
return icons.SEARCH;
case 'Housekeeping':
return icons.HOUSEKEEPING;
case 'RescanFolders':
return icons.RESCAN;
case 'RefreshArtist':
return icons.REFRESH;
case 'RssSync':

@ -28,10 +28,6 @@ const links = [
title: 'Add New',
to: '/add/search'
},
{
title: 'Import',
to: '/add/import'
},
{
title: 'Mass Editor',
to: '/artisteditor'

@ -11,7 +11,7 @@ import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
import { fetchArtist } from 'Store/Actions/artistActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchRootFolders } from 'Store/Actions/settingsActions';
import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions';
function getHandlerName(name) {
@ -275,8 +275,14 @@ class SignalRConnector extends Component {
// No-op for now, we may want this later
}
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
handleRootfolder = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'settings.rootFolders',
updateOnly: true,
...body.resource
});
}
}
handleTag = (body) => {

@ -82,6 +82,7 @@ import {
faRocket as fasRocket,
faSave as fasSave,
faSearch as fasSearch,
faSearchPlus as fasSearchPlus,
faSignOutAlt as fasSignOutAlt,
faSitemap as fasSitemap,
faSpinner as fasSpinner,
@ -110,6 +111,7 @@ import {
export const ACTIONS = fasBolt;
export const ACTIVITY = farClock;
export const ADD = fasPlus;
export const ADD_MISSING_ARTISTS = fasSearchPlus;
export const ALTERNATE_TITLES = farClone;
export const ADVANCED_SETTINGS = fasCog;
export const ARROW_LEFT = fasArrowCircleLeft;
@ -182,6 +184,7 @@ export const QUICK = fasRocket;
export const REFRESH = fasSync;
export const REMOVE = fasTimes;
export const REORDER = fasBars;
export const RESCAN = fasFolderOpen;
export const RESTART = fasRedoAlt;
export const RESTORE = fasHistory;
export const RETAG = fasEdit;

@ -30,8 +30,8 @@ import styles from './InteractiveImportModalContent.css';
const columns = [
{
name: 'relativePath',
label: 'Relative Path',
name: 'path',
label: 'Path',
isSortable: true,
isVisible: true
},

@ -1,4 +1,4 @@
.relativePath {
.path {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;

@ -149,7 +149,7 @@ class InteractiveImportRow extends Component {
const {
id,
allowArtistChange,
relativePath,
path,
artist,
album,
albumReleaseId,
@ -190,7 +190,7 @@ class InteractiveImportRow extends Component {
const pathCellContents = (
<div>
{relativePath}
{path}
</div>
);
@ -213,8 +213,8 @@ class InteractiveImportRow extends Component {
/>
<TableRowCell
className={styles.relativePath}
title={relativePath}
className={styles.path}
title={path}
>
{pathCell}
</TableRowCell>
@ -328,7 +328,7 @@ class InteractiveImportRow extends Component {
audioTags={audioTags}
sortKey='mediumNumber'
sortDirection={sortDirections.ASCENDING}
filename={relativePath}
filename={path}
onModalClose={this.onSelectTrackModalClose}
/>
@ -349,7 +349,7 @@ class InteractiveImportRow extends Component {
InteractiveImportRow.propTypes = {
id: PropTypes.number.isRequired,
allowArtistChange: PropTypes.bool.isRequired,
relativePath: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
artist: PropTypes.object,
album: PropTypes.object,
albumReleaseId: PropTypes.number,

@ -75,7 +75,6 @@ class OrganizePreviewModalContent extends Component {
error,
items,
trackFormat,
path,
onModalClose
} = this.props;
@ -113,13 +112,6 @@ class OrganizePreviewModalContent extends Component {
!isFetching && isPopulated && !!items.length &&
<div>
<Alert>
<div>
All paths are relative to:
<span className={styles.path}>
{path}
</span>
</div>
<div>
Naming pattern:
<span className={styles.trackFormat}>

@ -74,7 +74,6 @@ class RetagPreviewModalContent extends Component {
isPopulated,
error,
items,
path,
onModalClose
} = this.props;
@ -112,12 +111,6 @@ class RetagPreviewModalContent extends Component {
!isFetching && isPopulated && !!items.length &&
<div>
<Alert>
<div>
All paths are relative to:
<span className={styles.path}>
{path}
</span>
</div>
<div>
MusicBrainz identifiers will also be added to the files; these are not shown below.
</div>
@ -130,7 +123,7 @@ class RetagPreviewModalContent extends Component {
<RetagPreviewRow
key={item.trackFileId}
id={item.trackFileId}
path={item.relativePath}
path={item.path}
changes={item.changes}
isSelected={selectedState[item.trackFileId]}
onSelectedChange={this.onSelectedChange}

@ -1,27 +0,0 @@
.link {
composes: link from '~Components/Link/Link.css';
}
.unavailablePath {
display: flex;
align-items: center;
}
.unavailableLabel {
composes: label from '~Components/Label.css';
margin-left: 10px;
}
.freeSpace,
.unmappedFolders {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 150px;
}
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 45px;
}

@ -1,81 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import { icons, kinds } from 'Helpers/Props';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import TableRow from 'Components/Table/TableRow';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import styles from './RootFolderRow.css';
function RootFolderRow(props) {
const {
id,
path,
accessible,
freeSpace,
unmappedFolders,
onDeletePress
} = props;
const isUnavailable = !accessible;
return (
<TableRow>
<TableRowCell>
{
isUnavailable ?
<div className={styles.unavailablePath}>
{path}
<Label
className={styles.unavailableLabel}
kind={kinds.DANGER}
>
Unavailable
</Label>
</div> :
<Link
className={styles.link}
to={`/add/import/${id}`}
>
{path}
</Link>
}
</TableRowCell>
<TableRowCell className={styles.freeSpace}>
{(isUnavailable || isNaN(freeSpace)) ? '-' : formatBytes(freeSpace)}
</TableRowCell>
<TableRowCell className={styles.unmappedFolders}>
{isUnavailable ? '-' : unmappedFolders.length}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
title="Remove root folder"
name={icons.REMOVE}
onPress={onDeletePress}
/>
</TableRowCell>
</TableRow>
);
}
RootFolderRow.propTypes = {
id: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
accessible: PropTypes.bool.isRequired,
freeSpace: PropTypes.number,
unmappedFolders: PropTypes.arrayOf(PropTypes.object).isRequired,
onDeletePress: PropTypes.func.isRequired
};
RootFolderRow.defaultProps = {
unmappedFolders: []
};
export default RootFolderRow;

@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import { deleteRootFolder } from 'Store/Actions/rootFolderActions';
import RootFolderRow from './RootFolderRow';
function createMapDispatchToProps(dispatch, props) {
return {
onDeletePress() {
dispatch(deleteRootFolder({ id: props.id }));
}
};
}
export default connect(null, createMapDispatchToProps)(RootFolderRow);

@ -1,81 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import RootFolderRowConnector from './RootFolderRowConnector';
const rootFolderColumns = [
{
name: 'path',
label: 'Path',
isVisible: true
},
{
name: 'freeSpace',
label: 'Free Space',
isVisible: true
},
{
name: 'unmappedFolders',
label: 'Unmapped Folders',
isVisible: true
},
{
name: 'actions',
isVisible: true
}
];
function RootFolders(props) {
const {
isFetching,
isPopulated,
error,
items
} = props;
if (isFetching && !isPopulated) {
return (
<LoadingIndicator />
);
}
if (!isFetching && !!error) {
return (
<div>Unable to load root folders</div>
);
}
return (
<Table
columns={rootFolderColumns}
>
<TableBody>
{
items.map((rootFolder) => {
return (
<RootFolderRowConnector
key={rootFolder.id}
id={rootFolder.id}
path={rootFolder.path}
accessible={rootFolder.accessible}
freeSpace={rootFolder.freeSpace}
unmappedFolders={rootFolder.unmappedFolders}
/>
);
})
}
</TableBody>
</Table>
);
}
RootFolders.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default RootFolders;

@ -1,46 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import RootFolders from './RootFolders';
function createMapStateToProps() {
return createSelector(
(state) => state.rootFolders,
(rootFolders) => {
return rootFolders;
}
);
}
const mapDispatchToProps = {
dispatchFetchRootFolders: fetchRootFolders
};
class RootFoldersConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchRootFolders();
}
//
// Render
render() {
return (
<RootFolders
{...this.props}
/>
);
}
}
RootFoldersConnector.propTypes = {
dispatchFetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector);

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import parseUrl from 'Utilities/String/parseUrl';
import { getSearchResults, clearSearchResults } from 'Store/Actions/searchActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchRootFolders } from 'Store/Actions/settingsActions';
import AddNewItem from './AddNewItem';
function createMapStateToProps() {

@ -4,8 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import sortByName from 'Utilities/Array/sortByName';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import { fetchImportLists, deleteImportList } from 'Store/Actions/settingsActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchImportLists, deleteImportList, fetchRootFolders } from 'Store/Actions/settingsActions';
import ImportLists from './ImportLists';
function createMapStateToProps() {

@ -10,9 +10,8 @@ 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 RootFoldersConnector from 'RootFolder/RootFoldersConnector';
import RootFoldersConnector from './RootFolder/RootFoldersConnector';
import NamingConnector from './Naming/NamingConnector';
import AddRootFolderConnector from './RootFolder/AddRootFolderConnector';
const rescanAfterRefreshOptions = [
{ key: 'always', value: 'Always' },
@ -64,6 +63,7 @@ class MediaManagement extends Component {
/>
<PageContentBodyConnector>
<RootFoldersConnector />
<NamingConnector />
{
@ -427,11 +427,6 @@ class MediaManagement extends Component {
}
</Form>
}
<FieldSet legend="Root Folders">
<RootFoldersConnector />
<AddRootFolderConnector />
</FieldSet>
</PageContentBodyConnector>
</PageContent>
);

@ -1,7 +0,0 @@
.addRootFolderButtonContainer {
margin-top: 20px;
}
.importButtonIcon {
margin-right: 8px;
}

@ -1,71 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons, kinds, sizes } from 'Helpers/Props';
import Button from 'Components/Link/Button';
import Icon from 'Components/Icon';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import styles from './AddRootFolder.css';
class AddRootFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddNewRootFolderModalOpen: false
};
}
//
// Lifecycle
onAddNewRootFolderPress = () => {
this.setState({ isAddNewRootFolderModalOpen: true });
}
onNewRootFolderSelect = ({ value }) => {
this.props.onNewRootFolderSelect(value);
}
onAddRootFolderModalClose = () => {
this.setState({ isAddNewRootFolderModalOpen: false });
}
//
// Render
render() {
return (
<div className={styles.addRootFolderButtonContainer}>
<Button
kind={kinds.PRIMARY}
size={sizes.LARGE}
onPress={this.onAddNewRootFolderPress}
>
<Icon
className={styles.importButtonIcon}
name={icons.DRIVE}
/>
Add Root Folder
</Button>
<FileBrowserModal
isOpen={this.state.isAddNewRootFolderModalOpen}
name="rootFolderPath"
value=""
onChange={this.onNewRootFolderSelect}
onModalClose={this.onAddRootFolderModalClose}
/>
</div>
);
}
}
AddRootFolder.propTypes = {
onNewRootFolderSelect: PropTypes.func.isRequired
};
export default AddRootFolder;

@ -1,13 +0,0 @@
import { connect } from 'react-redux';
import AddRootFolder from './AddRootFolder';
import { addRootFolder } from 'Store/Actions/rootFolderActions';
function createMapDispatchToProps(dispatch) {
return {
onNewRootFolderSelect(path) {
dispatch(addRootFolder({ path }));
}
};
}
export default connect(null, createMapDispatchToProps)(AddRootFolder);

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

@ -0,0 +1,58 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { cancelSaveRootFolder } from 'Store/Actions/settingsActions';
import EditRootFolderModal from './EditRootFolderModal';
function createMapDispatchToProps(dispatch, props) {
const section = 'settings.rootFolders';
return {
dispatchClearPendingChanges() {
dispatch(clearPendingChanges({ section }));
},
dispatchCancelSaveRootFolder() {
dispatch(cancelSaveRootFolder({ section }));
}
};
}
class EditRootFolderModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.dispatchClearPendingChanges();
this.props.dispatchCancelSaveRootFolder();
this.props.onModalClose();
}
//
// Render
render() {
const {
dispatchClearPendingChanges,
dispatchCancelSaveRootFolder,
...otherProps
} = this.props;
return (
<EditRootFolderModal
{...otherProps}
onModalClose={this.onModalClose}
/>
);
}
}
EditRootFolderModalConnector.propTypes = {
onModalClose: PropTypes.func.isRequired,
dispatchClearPendingChanges: PropTypes.func.isRequired,
dispatchCancelSaveRootFolder: PropTypes.func.isRequired
};
export default connect(null, createMapDispatchToProps)(EditRootFolderModalConnector);

@ -0,0 +1,15 @@
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: auto;
}
.hideMetadataProfile {
composes: group from '~Components/Form/FormGroup.css';
display: none;
}
.labelIcon {
margin-left: 8px;
}

@ -0,0 +1,217 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormLabel from 'Components/Form/FormLabel';
import FormInputGroup from 'Components/Form/FormInputGroup';
import Popover from 'Components/Tooltip/Popover';
import ArtistMonitoringOptionsPopoverContent from 'AddArtist/ArtistMonitoringOptionsPopoverContent';
import ArtistMetadataProfilePopoverContent from 'AddArtist/ArtistMetadataProfilePopoverContent';
import styles from './EditRootFolderModalContent.css';
function EditRootFolderModalContent(props) {
const {
advancedSettings,
isFetching,
error,
isSaving,
saveError,
item,
onInputChange,
onModalClose,
onSavePress,
onDeleteRootFolderPress,
showMetadataProfile,
...otherProps
} = props;
const {
id,
name,
path,
defaultQualityProfileId,
defaultMetadataProfileId,
defaultMonitorOption,
defaultTags
} = item;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{id ? 'Edit Root Folder' : 'Add Root Folder'}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div>Unable to add a new root folder, please try again.</div>
}
{
!isFetching && !error &&
<Form {...otherProps}>
<FormGroup>
<FormLabel>Name</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Path</FormLabel>
<FormInputGroup
type={id ? inputTypes.TEXT : inputTypes.PATH}
readOnly={!!id}
name="path"
helpText="Root Folder containing your music library"
helpTextWarning="This must be different to the directory where your download client puts files"
{...path}
onChange={onInputChange}
/>
</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="defaultMonitorOption"
onChange={onInputChange}
{...defaultMonitorOption}
helpText="Default Monitoring Options for albums by artists detected in this folder"
/>
</FormGroup>
<FormGroup>
<FormLabel>Quality Profile</FormLabel>
<FormInputGroup
type={inputTypes.QUALITY_PROFILE_SELECT}
name="defaultQualityProfileId"
helpText="Default Quality Profile for artists detected in this folder"
{...defaultQualityProfileId}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup className={showMetadataProfile ? undefined : styles.hideMetadataProfile}>
<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="defaultMetadataProfileId"
helpText="Default Metadata Profile for artists detected in this folder"
{...defaultMetadataProfileId}
includeNone={true}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Default Lidarr Tags</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="defaultTags"
helpText="Default Lidarr Tags for artists detected in this folder"
{...defaultTags}
onChange={onInputChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
{
id &&
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteRootFolderPress}
>
Delete
</Button>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<SpinnerErrorButton
isSpinning={isSaving}
error={saveError}
onPress={onSavePress}
>
Save
</SpinnerErrorButton>
</ModalFooter>
</ModalContent>
);
}
EditRootFolderModalContent.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
showMetadataProfile: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onSavePress: PropTypes.func.isRequired,
onDeleteRootFolderPress: PropTypes.func
};
export default EditRootFolderModalContent;

@ -0,0 +1,84 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector';
import { setRootFolderValue, saveRootFolder } from 'Store/Actions/settingsActions';
import EditRootFolderModalContent from './EditRootFolderModalContent';
function createMapStateToProps() {
return createSelector(
(state, { id }) => id,
(state) => state.settings.advancedSettings,
(state) => state.settings.metadataProfiles,
(state) => state.settings.rootFolders,
createProviderSettingsSelector('rootFolders'),
(id, advancedSettings, metadataProfiles, rootFolders, rootFolderSettings) => {
return {
advancedSettings,
showMetadataProfile: metadataProfiles.items.length > 1,
...rootFolderSettings,
isFetching: rootFolders.isFetching
};
}
);
}
const mapDispatchToProps = {
setRootFolderValue,
saveRootFolder
};
class EditRootFolderModalContentConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps, prevState) {
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
this.props.onModalClose();
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setRootFolderValue({ name, value });
}
onSavePress = () => {
this.props.saveRootFolder({ id: this.props.id });
if (this.props.onRootFolderAdded) {
this.props.onRootFolderAdded(this.props.item.path);
}
}
//
// Render
render() {
return (
<EditRootFolderModalContent
{...this.props}
onSavePress={this.onSavePress}
onInputChange={this.onInputChange}
/>
);
}
}
EditRootFolderModalContentConnector.propTypes = {
id: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
item: PropTypes.object.isRequired,
setRootFolderValue: PropTypes.func.isRequired,
saveRootFolder: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired,
onRootFolderAdded: PropTypes.func
};
export default connect(createMapStateToProps, mapDispatchToProps)(EditRootFolderModalContentConnector);

@ -0,0 +1,19 @@
.rootFolder {
composes: card from '~Components/Card.css';
width: 290px;
}
.name {
@add-mixin truncate;
margin-bottom: 20px;
font-weight: 300;
font-size: 24px;
}
.enabled {
display: flex;
flex-wrap: wrap;
margin-top: 5px;
}

@ -0,0 +1,116 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { kinds } from 'Helpers/Props';
import Card from 'Components/Card';
import Label from 'Components/Label';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import EditRootFolderModalConnector from './EditRootFolderModalConnector';
import styles from './RootFolder.css';
class RootFolder extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isEditRootFolderModalOpen: false,
isDeleteRootFolderModalOpen: false
};
}
//
// Listeners
onEditRootFolderPress = () => {
this.setState({ isEditRootFolderModalOpen: true });
}
onEditRootFolderModalClose = () => {
this.setState({ isEditRootFolderModalOpen: false });
}
onDeleteRootFolderPress = () => {
this.setState({
isEditRootFolderModalOpen: false,
isDeleteRootFolderModalOpen: true
});
}
onDeleteRootFolderModalClose= () => {
this.setState({ isDeleteRootFolderModalOpen: false });
}
onConfirmDeleteRootFolder = () => {
this.props.onConfirmDeleteRootFolder(this.props.id);
}
//
// Render
render() {
const {
id,
name,
path,
qualityProfile,
metadataProfile
} = this.props;
return (
<Card
className={styles.rootFolder}
overlayContent={true}
onPress={this.onEditRootFolderPress}
>
<div className={styles.name}>
{name}
</div>
<div className={styles.enabled}>
<Label kind={kinds.SUCCESS}>
{path}
</Label>
<Label kind={kinds.SUCCESS}>
{qualityProfile.name}
</Label>
<Label kind={kinds.SUCCESS}>
{metadataProfile.name}
</Label>
</div>
<EditRootFolderModalConnector
id={id}
isOpen={this.state.isEditRootFolderModalOpen}
onModalClose={this.onEditRootFolderModalClose}
onDeleteRootFolderPress={this.onDeleteRootFolderPress}
/>
<ConfirmModal
isOpen={this.state.isDeleteRootFolderModalOpen}
kind={kinds.DANGER}
title="Delete Root Folder"
message={`Are you sure you want to delete the root folder '${name}'?`}
confirmLabel="Delete"
onConfirm={this.onConfirmDeleteRootFolder}
onCancel={this.onDeleteRootFolderModalClose}
/>
</Card>
);
}
}
RootFolder.propTypes = {
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
qualityProfile: PropTypes.object.isRequired,
metadataProfile: PropTypes.object.isRequired,
onConfirmDeleteRootFolder: PropTypes.func.isRequired
};
export default RootFolder;

@ -0,0 +1,20 @@
.rootFolders {
display: flex;
flex-wrap: wrap;
}
.addRootFolder {
composes: rootFolder from '~./RootFolder.css';
background-color: $cardAlternateBackgroundColor;
color: $gray;
text-align: center;
}
.center {
display: inline-block;
padding: 5px 20px 0;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
}

@ -0,0 +1,105 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByName from 'Utilities/Array/sortByName';
import { icons } from 'Helpers/Props';
import FieldSet from 'Components/FieldSet';
import Card from 'Components/Card';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import RootFolder from './RootFolder';
import EditRootFolderModalConnector from './EditRootFolderModalConnector';
import styles from './RootFolders.css';
class RootFolders extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isAddRootFolderModalOpen: false
};
}
//
// Listeners
onAddRootFolderPress = () => {
this.setState({ isAddRootFolderModalOpen: true });
}
onAddRootFolderModalClose = () => {
this.setState({ isAddRootFolderModalOpen: false });
}
//
// Render
render() {
const {
items,
qualityProfiles,
metadataProfiles,
onConfirmDeleteRootFolder,
...otherProps
} = this.props;
return (
<FieldSet legend="Root Folders">
<PageSectionContent
errorMessage="Unable to load root folders"
{...otherProps}
>
<div className={styles.rootFolders}>
{
items.sort(sortByName).map((item) => {
const qualityProfile = qualityProfiles.find((profile) => profile.id === item.defaultQualityProfileId);
const metadataProfile = metadataProfiles.find((profile) => profile.id === item.defaultMetadataProfileId);
return (
<RootFolder
key={item.id}
{...item}
qualityProfile={qualityProfile}
metadataProfile={metadataProfile}
onConfirmDeleteRootFolder={onConfirmDeleteRootFolder}
/>
);
})
}
<Card
className={styles.addRootFolder}
onPress={this.onAddRootFolderPress}
>
<div className={styles.center}>
<Icon
name={icons.ADD}
size={45}
/>
</div>
</Card>
</div>
<EditRootFolderModalConnector
isOpen={this.state.isAddRootFolderModalOpen}
onModalClose={this.onAddRootFolderModalClose}
/>
</PageSectionContent>
</FieldSet>
);
}
}
RootFolders.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
qualityProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
metadataProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
onConfirmDeleteRootFolder: PropTypes.func.isRequired
};
export default RootFolders;

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchRootFolders, deleteRootFolder } from 'Store/Actions/settingsActions';
import RootFolders from './RootFolders';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.rootFolders,
(state) => state.settings.qualityProfiles,
(state) => state.settings.metadataProfiles,
(rootFolders, quality, metadata) => {
return {
qualityProfiles: quality.items,
metadataProfiles: metadata.items,
...rootFolders
};
}
);
}
const mapDispatchToProps = {
dispatchFetchRootFolders: fetchRootFolders,
dispatchDeleteRootFolder: deleteRootFolder
};
class RootFoldersConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.dispatchFetchRootFolders();
}
//
// Listeners
onConfirmDeleteRootFolder = (id) => {
this.props.dispatchDeleteRootFolder({ id });
}
//
// Render
render() {
return (
<RootFolders
{...this.props}
onConfirmDeleteRootFolder={this.onConfirmDeleteRootFolder}
/>
);
}
}
RootFoldersConnector.propTypes = {
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchDeleteRootFolder: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(RootFoldersConnector);

@ -0,0 +1,76 @@
import { createAction } from 'redux-actions';
import { createThunk } from 'Store/thunks';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createSaveProviderHandler, { createCancelSaveProviderHandler } from 'Store/Actions/Creators/createSaveProviderHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
//
// Variables
export const section = 'settings.rootFolders';
//
// Actions Types
export const FETCH_ROOT_FOLDERS = 'settings/rootFolders/fetchRootFolders';
export const SET_ROOT_FOLDER_VALUE = 'settings/rootFolders/setRootFolderValue';
export const SAVE_ROOT_FOLDER = 'settings/rootFolders/saveRootFolder';
export const CANCEL_SAVE_ROOT_FOLDER = 'settings/rootFolders/cancelSaveRootFolder';
export const DELETE_ROOT_FOLDER = 'settings/rootFolders/deleteRootFolder';
//
// Action Creators
export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS);
export const saveRootFolder = createThunk(SAVE_ROOT_FOLDER);
export const cancelSaveRootFolder = createThunk(CANCEL_SAVE_ROOT_FOLDER);
export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER);
export const setRootFolderValue = createAction(SET_ROOT_FOLDER_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Details
export default {
//
// State
defaultState: {
isFetching: false,
isPopulated: false,
error: null,
schema: {
defaultTags: []
},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_ROOT_FOLDERS]: createFetchHandler(section, '/rootFolder'),
[SAVE_ROOT_FOLDER]: createSaveProviderHandler(section, '/rootFolder'),
[CANCEL_SAVE_ROOT_FOLDER]: createCancelSaveProviderHandler(section),
[DELETE_ROOT_FOLDER]: createRemoveItemHandler(section, '/rootFolder')
},
//
// Reducers
reducers: {
[SET_ROOT_FOLDER_VALUE]: createSetSettingValueReducer(section)
}
};

@ -1,327 +0,0 @@
import _ from 'lodash';
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import getNewArtist from 'Utilities/Artist/getNewArtist';
import { createThunk, handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import { set, removeItem, updateItem } from './baseActions';
import { fetchRootFolders } from './rootFolderActions';
//
// Variables
export const section = 'importArtist';
let concurrentLookups = 0;
let abortCurrentLookup = null;
const queue = [];
//
// State
export const defaultState = {
isLookingUpArtist: false,
isImporting: false,
isImported: false,
importError: null,
items: []
};
//
// Actions Types
export const QUEUE_LOOKUP_ARTIST = 'importArtist/queueLookupArtist';
export const START_LOOKUP_ARTIST = 'importArtist/startLookupArtist';
export const CANCEL_LOOKUP_ARTIST = 'importArtist/cancelLookupArtist';
export const LOOKUP_UNSEARCHED_ARTIST = 'importArtist/lookupUnsearchedArtist';
export const CLEAR_IMPORT_ARTIST = 'importArtist/clearImportArtist';
export const SET_IMPORT_ARTIST_VALUE = 'importArtist/setImportArtistValue';
export const IMPORT_ARTIST = 'importArtist/importArtist';
//
// Action Creators
export const queueLookupArtist = createThunk(QUEUE_LOOKUP_ARTIST);
export const startLookupArtist = createThunk(START_LOOKUP_ARTIST);
export const importArtist = createThunk(IMPORT_ARTIST);
export const lookupUnsearchedArtist = createThunk(LOOKUP_UNSEARCHED_ARTIST);
export const clearImportArtist = createAction(CLEAR_IMPORT_ARTIST);
export const cancelLookupArtist = createAction(CANCEL_LOOKUP_ARTIST);
export const setImportArtistValue = createAction(SET_IMPORT_ARTIST_VALUE, (payload) => {
return {
section,
...payload
};
});
//
// Action Handlers
export const actionHandlers = handleThunks({
[QUEUE_LOOKUP_ARTIST]: function(getState, payload, dispatch) {
const {
name,
path,
term,
topOfQueue = false
} = payload;
const state = getState().importArtist;
const item = _.find(state.items, { id: name }) || {
id: name,
term,
path,
isFetching: false,
isPopulated: false,
error: null
};
dispatch(updateItem({
section,
...item,
term,
isQueued: true,
items: []
}));
const itemIndex = queue.indexOf(item.id);
if (itemIndex >= 0) {
queue.splice(itemIndex, 1);
}
if (topOfQueue) {
queue.unshift(item.id);
} else {
queue.push(item.id);
}
if (term && term.length > 2) {
dispatch(startLookupArtist({ start: true }));
}
},
[START_LOOKUP_ARTIST]: function(getState, payload, dispatch) {
if (concurrentLookups >= 1) {
return;
}
const state = getState().importArtist;
const {
isLookingUpArtist,
items
} = state;
const queueId = queue[0];
if (payload.start && !isLookingUpArtist) {
dispatch(set({ section, isLookingUpArtist: true }));
} else if (!isLookingUpArtist) {
return;
} else if (!queueId) {
dispatch(set({ section, isLookingUpArtist: false }));
return;
}
concurrentLookups++;
queue.splice(0, 1);
const queued = items.find((i) => i.id === queueId);
dispatch(updateItem({
section,
id: queued.id,
isFetching: true
}));
const { request, abortRequest } = createAjaxRequest({
url: '/artist/lookup',
data: {
term: queued.term
}
});
abortCurrentLookup = abortRequest;
request.done((data) => {
dispatch(updateItem({
section,
id: queued.id,
isFetching: false,
isPopulated: true,
error: null,
items: data,
isQueued: false,
selectedArtist: queued.selectedArtist || data[0],
updateOnly: true
}));
});
request.fail((xhr) => {
dispatch(updateItem({
section,
id: queued.id,
isFetching: false,
isPopulated: false,
error: xhr,
isQueued: false,
updateOnly: true
}));
});
request.always(() => {
concurrentLookups--;
dispatch(startLookupArtist());
});
},
[LOOKUP_UNSEARCHED_ARTIST]: function(getState, payload, dispatch) {
const state = getState().importArtist;
if (state.isLookingUpArtist) {
return;
}
state.items.forEach((item) => {
const id = item.id;
if (
!item.isPopulated &&
!queue.includes(id)
) {
queue.push(item.id);
}
});
if (queue.length) {
dispatch(startLookupArtist({ start: true }));
}
},
[IMPORT_ARTIST]: function(getState, payload, dispatch) {
dispatch(set({ section, isImporting: true }));
const ids = payload.ids;
const items = getState().importArtist.items;
const addedIds = [];
const allNewArtist = ids.reduce((acc, id) => {
const item = _.find(items, { id });
const selectedArtist = item.selectedArtist;
// Make sure we have a selected artist and
// the same artist hasn't been added yet.
if (selectedArtist && !_.some(acc, { foreignArtistId: selectedArtist.foreignArtistId })) {
const newArtist = getNewArtist(_.cloneDeep(selectedArtist), item);
newArtist.path = item.path;
addedIds.push(id);
acc.push(newArtist);
}
return acc;
}, []);
const promise = createAjaxRequest({
url: '/artist/import',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(allNewArtist)
}).request;
promise.done((data) => {
dispatch(batchActions([
set({
section,
isImporting: false,
isImported: true
}),
...data.map((artist) => updateItem({ section: 'artist', ...artist })),
...addedIds.map((id) => removeItem({ section, id }))
]));
dispatch(fetchRootFolders());
});
promise.fail((xhr) => {
dispatch(batchActions(
set({
section,
isImporting: false,
isImported: true
}),
addedIds.map((id) => updateItem({
section,
id,
importError: xhr
}))
));
});
}
});
//
// Reducers
export const reducers = createHandleActions({
[CANCEL_LOOKUP_ARTIST]: function(state) {
queue.splice(0, queue.length);
const items = state.items.map((item) => {
if (item.isQueued) {
return {
...item,
isQueued: false
};
}
return item;
});
return Object.assign({}, state, {
isLookingUpArtist: false,
items
});
},
[CLEAR_IMPORT_ARTIST]: function(state) {
if (abortCurrentLookup) {
abortCurrentLookup();
abortCurrentLookup = null;
}
queue.splice(0, queue.length);
return Object.assign({}, state, defaultState);
},
[SET_IMPORT_ARTIST_VALUE]: function(state, { payload }) {
const newState = getSectionState(state, section);
const items = newState.items;
const index = _.findIndex(items, { id: payload.id });
newState.items = [...items];
if (index >= 0) {
const item = items[index];
newState.items.splice(index, 1, { ...item, ...payload });
} else {
newState.items.push({ ...payload });
}
return updateSectionState(state, section, newState);
}
}, defaultState, section);

@ -8,7 +8,6 @@ import * as albums from './albumActions';
import * as trackFiles from './trackFileActions';
import * as albumHistory from './albumHistoryActions';
import * as history from './historyActions';
import * as importArtist from './importArtistActions';
import * as interactiveImportActions from './interactiveImportActions';
import * as oAuth from './oAuthActions';
import * as organizePreview from './organizePreviewActions';
@ -17,7 +16,6 @@ import * as paths from './pathActions';
import * as providerOptions from './providerOptionActions';
import * as queue from './queueActions';
import * as releases from './releaseActions';
import * as rootFolders from './rootFolderActions';
import * as albumStudio from './albumStudioActions';
import * as artist from './artistActions';
import * as artistEditor from './artistEditorActions';
@ -41,7 +39,6 @@ export default [
trackFiles,
albumHistory,
history,
importArtist,
interactiveImportActions,
oAuth,
organizePreview,
@ -50,7 +47,6 @@ export default [
providerOptions,
queue,
releases,
rootFolders,
albumStudio,
artist,
artistEditor,

@ -34,10 +34,10 @@ export const defaultState = {
recentFolders: [],
importMode: 'move',
sortPredicates: {
relativePath: function(item, direction) {
const relativePath = item.relativePath;
path: function(item, direction) {
const path = item.path;
return relativePath.toLowerCase();
return path.toLowerCase();
},
artist: function(item, direction) {

@ -1,97 +0,0 @@
import { batchActions } from 'redux-batched-actions';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { createThunk, handleThunks } from 'Store/thunks';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createRemoveItemHandler from './Creators/createRemoveItemHandler';
import { set, updateItem } from './baseActions';
//
// Variables
export const section = 'rootFolders';
//
// State
export const defaultState = {
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
items: []
};
//
// Actions Types
export const FETCH_ROOT_FOLDERS = 'rootFolders/fetchRootFolders';
export const ADD_ROOT_FOLDER = 'rootFolders/addRootFolder';
export const DELETE_ROOT_FOLDER = 'rootFolders/deleteRootFolder';
//
// Action Creators
export const fetchRootFolders = createThunk(FETCH_ROOT_FOLDERS);
export const addRootFolder = createThunk(ADD_ROOT_FOLDER);
export const deleteRootFolder = createThunk(DELETE_ROOT_FOLDER);
//
// Action Handlers
export const actionHandlers = handleThunks({
[FETCH_ROOT_FOLDERS]: createFetchHandler('rootFolders', '/rootFolder'),
[DELETE_ROOT_FOLDER]: createRemoveItemHandler(
'rootFolders',
'/rootFolder',
(state) => state.rootFolders
),
[ADD_ROOT_FOLDER]: function(getState, payload, dispatch) {
const path = payload.path;
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/rootFolder',
method: 'POST',
data: JSON.stringify({ path }),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
updateItem({
section,
...data
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

@ -20,6 +20,7 @@ import qualityDefinitions from './Settings/qualityDefinitions';
import qualityProfiles from './Settings/qualityProfiles';
import releaseProfiles from './Settings/releaseProfiles';
import remotePathMappings from './Settings/remotePathMappings';
import rootFolders from './Settings/rootFolders';
import ui from './Settings/ui';
export * from './Settings/delayProfiles';
@ -41,6 +42,7 @@ export * from './Settings/qualityDefinitions';
export * from './Settings/qualityProfiles';
export * from './Settings/releaseProfiles';
export * from './Settings/remotePathMappings';
export * from './Settings/rootFolders';
export * from './Settings/ui';
//
@ -73,6 +75,7 @@ export const defaultState = {
qualityProfiles: qualityProfiles.defaultState,
releaseProfiles: releaseProfiles.defaultState,
remotePathMappings: remotePathMappings.defaultState,
rootFolders: rootFolders.defaultState,
ui: ui.defaultState
};
@ -113,6 +116,7 @@ export const actionHandlers = handleThunks({
...qualityProfiles.actionHandlers,
...releaseProfiles.actionHandlers,
...remotePathMappings.actionHandlers,
...rootFolders.actionHandlers,
...ui.actionHandlers
});
@ -144,6 +148,7 @@ export const reducers = createHandleActions({
...qualityProfiles.reducers,
...releaseProfiles.reducers,
...remotePathMappings.reducers,
...rootFolders.reducers,
...ui.reducers
}, defaultState, section);

@ -45,11 +45,6 @@ export const defaultState = {
label: 'Path',
isVisible: false
},
{
name: 'relativePath',
label: 'Relative Path',
isVisible: false
},
{
name: 'duration',
label: 'Duration',

@ -28,8 +28,8 @@ const columns = [
isVisible: true
},
{
name: 'relativePath',
label: 'Relative Path',
name: 'path',
label: 'Path',
isVisible: true
},
{

@ -65,7 +65,7 @@ function createMapStateToProps() {
const trackFile = _.find(trackFiles.items, { id: track.trackFileId });
return {
relativePath: trackFile.relativePath,
path: trackFile.path,
quality: trackFile.quality,
...track
};

@ -10,7 +10,7 @@ function TrackFileEditorRow(props) {
const {
id,
trackNumber,
relativePath,
path,
quality,
isSelected,
onSelectedChange
@ -29,7 +29,7 @@ function TrackFileEditorRow(props) {
</TableRowCell>
<TableRowCell>
{relativePath}
{path}
</TableRowCell>
<TableRowCell>
@ -44,7 +44,7 @@ function TrackFileEditorRow(props) {
TrackFileEditorRow.propTypes = {
id: PropTypes.number.isRequired,
trackNumber: PropTypes.string.isRequired,
relativePath: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
quality: PropTypes.object.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired

@ -69,7 +69,8 @@ class UnmappedFilesTable extends Component {
sortDirection,
onTableOptionChange,
onSortPress,
deleteUnmappedFile,
isScanningFolders,
onAddMissingArtistsPress,
...otherProps
} = this.props;
@ -80,6 +81,16 @@ class UnmappedFilesTable extends Component {
return (
<PageContent title="UnmappedFiles">
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Add missing"
iconName={icons.ADD_MISSING_ARTISTS}
isDisabled={isPopulated && !error && !items.length}
isSpinning={isScanningFolders}
onPress={onAddMissingArtistsPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
@ -148,7 +159,9 @@ UnmappedFilesTable.propTypes = {
sortDirection: PropTypes.oneOf(sortDirections.all),
onTableOptionChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
deleteUnmappedFile: PropTypes.func.isRequired
deleteUnmappedFile: PropTypes.func.isRequired,
isScanningFolders: PropTypes.bool.isRequired,
onAddMissingArtistsPress: PropTypes.func.isRequired
};
export default UnmappedFilesTable;

@ -5,17 +5,22 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import withCurrentPage from 'Components/withCurrentPage';
import UnmappedFilesTable from './UnmappedFilesTable';
function createMapStateToProps() {
return createSelector(
createClientSideCollectionSelector('trackFiles'),
createCommandExecutingSelector(commandNames.RESCAN_FOLDERS),
createDimensionsSelector(),
(
trackFiles,
isScanningFolders,
dimensionsState
) => {
// trackFiles could pick up mapped entries via signalR so filter again here
@ -27,6 +32,7 @@ function createMapStateToProps() {
return {
items: unmappedFiles,
...otherProps,
isScanningFolders,
isSmallScreen: dimensionsState.isSmallScreen
};
}
@ -49,6 +55,13 @@ function createMapDispatchToProps(dispatch, props) {
deleteUnmappedFile(id) {
dispatch(deleteTrackFile({ id }));
},
onAddMissingArtistsPress() {
dispatch(executeCommand({
name: commandNames.RESCAN_FOLDERS,
filter: 'matched'
}));
}
};
}

@ -39,7 +39,7 @@ namespace Lidarr.Api.V1.Albums
IMapCoversToLocal coverMapper,
IUpgradableSpecification upgradableSpecification,
IBroadcastSignalRMessage signalRBroadcaster,
ProfileExistsValidator profileExistsValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(albumService, artistStatisticsService, coverMapper, upgradableSpecification, signalRBroadcaster)
@ -54,7 +54,7 @@ namespace Lidarr.Api.V1.Albums
Put("/monitor", x => SetAlbumsMonitored());
PostValidator.RuleFor(s => s.ForeignAlbumId).NotEmpty();
PostValidator.RuleFor(s => s.Artist.QualityProfileId).SetValidator(profileExistsValidator);
PostValidator.RuleFor(s => s.Artist.QualityProfileId).SetValidator(qualityProfileExistsValidator);
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();

@ -53,7 +53,7 @@ namespace Lidarr.Api.V1.Artist
ArtistExistsValidator artistExistsValidator,
ArtistAncestorValidator artistAncestorValidator,
SystemFolderValidator systemFolderValidator,
ProfileExistsValidator profileExistsValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(signalRBroadcaster)
{
@ -85,7 +85,7 @@ namespace Lidarr.Api.V1.Artist
.SetValidator(systemFolderValidator)
.When(s => !s.Path.IsNullOrWhiteSpace());
SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(profileExistsValidator);
SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(qualityProfileExistsValidator);
SharedValidator.RuleFor(s => s.MetadataProfileId).SetValidator(metadataProfileExistsValidator);
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());

@ -62,7 +62,6 @@ namespace Lidarr.Api.V1.FileSystem
return _diskScanService.GetAudioFiles(path).Select(f => new
{
Path = f.FullName,
RelativePath = path.GetRelativePath(f.FullName),
Name = f.Name
});
}

@ -9,7 +9,7 @@ namespace Lidarr.Api.V1.ImportLists
public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper();
public ImportListModule(ImportListFactory importListFactory,
ProfileExistsValidator profileExistsValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(importListFactory, "importlist", ResourceMapper)
{
@ -17,7 +17,7 @@ namespace Lidarr.Api.V1.ImportLists
Http.Validation.RuleBuilderExtensions.ValidId(SharedValidator.RuleFor(s => s.MetadataProfileId));
SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath();
SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(profileExistsValidator);
SharedValidator.RuleFor(c => c.QualityProfileId).SetValidator(qualityProfileExistsValidator);
SharedValidator.RuleFor(c => c.MetadataProfileId).SetValidator(metadataProfileExistsValidator);
}

@ -71,7 +71,6 @@ namespace Lidarr.Api.V1.ManualImport
{
Id = resource.Id,
Path = resource.Path,
RelativePath = resource.RelativePath,
Name = resource.Name,
Size = resource.Size,
Artist = resource.Artist == null ? null : _artistService.GetArtist(resource.Artist.Id),

@ -14,7 +14,6 @@ namespace Lidarr.Api.V1.ManualImport
public class ManualImportResource : RestResource
{
public string Path { get; set; }
public string RelativePath { get; set; }
public string Name { get; set; }
public long Size { get; set; }
public ArtistResource Artist { get; set; }
@ -44,7 +43,6 @@ namespace Lidarr.Api.V1.ManualImport
{
Id = model.Id,
Path = model.Path,
RelativePath = model.RelativePath,
Name = model.Name,
Size = model.Size,
Artist = model.Artist.ToResource(),

@ -1,7 +1,9 @@
using System.Collections.Generic;
using FluentValidation;
using Lidarr.Http;
using Lidarr.Http.REST;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
@ -18,7 +20,9 @@ namespace Lidarr.Api.V1.RootFolders
MappedNetworkDriveValidator mappedNetworkDriveValidator,
StartupFolderValidator startupFolderValidator,
SystemFolderValidator systemFolderValidator,
FolderWritableValidator folderWritableValidator)
FolderWritableValidator folderWritableValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(signalRBroadcaster)
{
_rootFolderService = rootFolderService;
@ -26,17 +30,29 @@ namespace Lidarr.Api.V1.RootFolders
GetResourceAll = GetRootFolders;
GetResourceById = GetRootFolder;
CreateResource = CreateRootFolder;
UpdateResource = UpdateRootFolder;
DeleteResource = DeleteFolder;
SharedValidator.RuleFor(c => c.Path)
.Cascade(CascadeMode.StopOnFirstFailure)
.IsValidPath()
.SetValidator(rootFolderValidator)
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(startupFolderValidator)
.SetValidator(pathExistsValidator)
.SetValidator(systemFolderValidator)
.SetValidator(folderWritableValidator);
.Cascade(CascadeMode.StopOnFirstFailure)
.IsValidPath()
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(startupFolderValidator)
.SetValidator(pathExistsValidator)
.SetValidator(systemFolderValidator)
.SetValidator(folderWritableValidator);
PostValidator.RuleFor(c => c.Path)
.SetValidator(rootFolderValidator);
SharedValidator.RuleFor(c => c.Name)
.NotEmpty();
SharedValidator.RuleFor(c => c.DefaultMetadataProfileId)
.SetValidator(metadataProfileExistsValidator);
SharedValidator.RuleFor(c => c.DefaultQualityProfileId)
.SetValidator(qualityProfileExistsValidator);
}
private RootFolderResource GetRootFolder(int id)
@ -51,9 +67,21 @@ namespace Lidarr.Api.V1.RootFolders
return _rootFolderService.Add(model).Id;
}
private void UpdateRootFolder(RootFolderResource rootFolderResource)
{
var model = rootFolderResource.ToModel();
if (model.Path != rootFolderResource.Path)
{
throw new BadRequestException("Cannot edit root folder path");
}
_rootFolderService.Update(model);
}
private List<RootFolderResource> GetRootFolders()
{
return _rootFolderService.AllWithUnmappedFolders().ToResource();
return _rootFolderService.AllWithSpaceStats().ToResource();
}
private void DeleteFolder(int id)

@ -1,18 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
using NzbDrone.Core.Music;
using NzbDrone.Core.RootFolders;
namespace Lidarr.Api.V1.RootFolders
{
public class RootFolderResource : RestResource
{
public string Name { get; set; }
public string Path { get; set; }
public int DefaultMetadataProfileId { get; set; }
public int DefaultQualityProfileId { get; set; }
public MonitorTypes DefaultMonitorOption { get; set; }
public HashSet<int> DefaultTags { get; set; }
public bool Accessible { get; set; }
public long? FreeSpace { get; set; }
public long? TotalSpace { get; set; }
public List<UnmappedFolder> UnmappedFolders { get; set; }
}
public static class RootFolderResourceMapper
@ -28,11 +33,16 @@ namespace Lidarr.Api.V1.RootFolders
{
Id = model.Id,
Name = model.Name,
Path = model.Path,
DefaultMetadataProfileId = model.DefaultMetadataProfileId,
DefaultQualityProfileId = model.DefaultQualityProfileId,
DefaultMonitorOption = model.DefaultMonitorOption,
DefaultTags = model.DefaultTags,
Accessible = model.Accessible,
FreeSpace = model.FreeSpace,
TotalSpace = model.TotalSpace,
UnmappedFolders = model.UnmappedFolders
};
}
@ -46,12 +56,13 @@ namespace Lidarr.Api.V1.RootFolders
return new RootFolder
{
Id = resource.Id,
Name = resource.Name,
Path = resource.Path,
//Accessible
//FreeSpace
//UnmappedFolders
DefaultMetadataProfileId = resource.DefaultMetadataProfileId,
DefaultQualityProfileId = resource.DefaultQualityProfileId,
DefaultMonitorOption = resource.DefaultMonitorOption,
DefaultTags = resource.DefaultTags
};
}

@ -13,7 +13,6 @@ namespace Lidarr.Api.V1.TrackFiles
{
public int ArtistId { get; set; }
public int AlbumId { get; set; }
public string RelativePath { get; set; }
public string Path { get; set; }
public long Size { get; set; }
public DateTime DateAdded { get; set; }
@ -74,7 +73,6 @@ namespace Lidarr.Api.V1.TrackFiles
ArtistId = artist.Id,
AlbumId = model.AlbumId,
Path = model.Path,
RelativePath = artist.Path.GetRelativePath(model.Path),
Size = model.Size,
DateAdded = model.DateAdded,
Quality = model.Quality,

@ -17,7 +17,7 @@ namespace Lidarr.Api.V1.Tracks
public int AlbumId { get; set; }
public List<int> TrackNumbers { get; set; }
public int TrackFileId { get; set; }
public string RelativePath { get; set; }
public string Path { get; set; }
public List<TagDifference> Changes { get; set; }
}
@ -36,7 +36,7 @@ namespace Lidarr.Api.V1.Tracks
AlbumId = model.AlbumId,
TrackNumbers = model.TrackNumbers.ToList(),
TrackFileId = model.TrackFileId,
RelativePath = model.RelativePath,
Path = model.Path,
Changes = model.Changes.Select(x => new TagDifference
{
Field = x.Key,

@ -153,5 +153,15 @@ namespace NzbDrone.Common.Extensions
{
return string.Join(separator, source.Select(predicate));
}
public static TSource MostCommon<TSource>(this IEnumerable<TSource> items)
{
return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key;
}
public static TResult MostCommon<TSource, TResult>(this IEnumerable<TSource> items, Func<TSource, TResult> predicate)
{
return items.Select(predicate).GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key;
}
}
}

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using FluentAssertions;
using Moq;
@ -6,6 +7,7 @@ using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Core.DiskSpace;
using NzbDrone.Core.Music;
using NzbDrone.Core.RootFolders;
using NzbDrone.Core.Test.Framework;
using NzbDrone.Test.Common;
@ -14,14 +16,20 @@ namespace NzbDrone.Core.Test.DiskSpace
[TestFixture]
public class DiskSpaceServiceFixture : CoreTest<DiskSpaceService>
{
private string _artistFolder;
private string _artostFolder2;
private RootFolder _rootDir;
private string _artistFolder1;
private string _artistFolder2;
[SetUp]
public void SetUp()
{
_artistFolder = @"G:\fasdlfsdf\artist".AsOsAgnostic();
_artostFolder2 = @"G:\fasdlfsdf\artist2".AsOsAgnostic();
_rootDir = new RootFolder { Path = @"G:\fasdlfsdf".AsOsAgnostic() };
_artistFolder1 = Path.Combine(_rootDir.Path, "artist1");
_artistFolder2 = Path.Combine(_rootDir.Path, "artist2");
Mocker.GetMock<IRootFolderService>()
.Setup(x => x.All())
.Returns(new List<RootFolder>() { _rootDir });
Mocker.GetMock<IDiskProvider>()
.Setup(v => v.GetMounts())
@ -59,9 +67,9 @@ namespace NzbDrone.Core.Test.DiskSpace
[Test]
public void should_check_diskspace_for_artist_folders()
{
GivenArtist(new Artist { Path = _artistFolder });
GivenArtist(new Artist { Path = _artistFolder1 });
GivenExistingFolder(_artistFolder);
GivenExistingFolder(_artistFolder1);
var freeSpace = Subject.GetFreeSpace();
@ -71,10 +79,10 @@ namespace NzbDrone.Core.Test.DiskSpace
[Test]
public void should_check_diskspace_for_same_root_folder_only_once()
{
GivenArtist(new Artist { Path = _artistFolder }, new Artist { Path = _artostFolder2 });
GivenArtist(new Artist { Path = _artistFolder1 }, new Artist { Path = _artistFolder2 });
GivenExistingFolder(_artistFolder);
GivenExistingFolder(_artostFolder2);
GivenExistingFolder(_artistFolder1);
GivenExistingFolder(_artistFolder2);
var freeSpace = Subject.GetFreeSpace();
@ -84,19 +92,6 @@ namespace NzbDrone.Core.Test.DiskSpace
.Verify(v => v.GetAvailableSpace(It.IsAny<string>()), Times.Once());
}
[Test]
public void should_not_check_diskspace_for_missing_artist_folders()
{
GivenArtist(new Artist { Path = _artistFolder });
var freeSpace = Subject.GetFreeSpace();
freeSpace.Should().BeEmpty();
Mocker.GetMock<IDiskProvider>()
.Verify(v => v.GetAvailableSpace(It.IsAny<string>()), Times.Never());
}
[TestCase("/boot")]
[TestCase("/var/lib/rancher")]
[TestCase("/var/lib/rancher/volumes")]
@ -114,6 +109,10 @@ namespace NzbDrone.Core.Test.DiskSpace
.Setup(v => v.GetMounts())
.Returns(new List<IMount> { mount.Object });
Mocker.GetMock<IRootFolderService>()
.Setup(x => x.All())
.Returns(new List<RootFolder>());
var freeSpace = Subject.GetFreeSpace();
freeSpace.Should().BeEmpty();

@ -9,7 +9,6 @@ using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Disk;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.DecisionEngine;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.MediaFiles.TrackImport;
@ -41,11 +40,15 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Build();
Mocker.GetMock<IRootFolderService>()
.Setup(s => s.GetBestRootFolderPath(It.IsAny<string>()))
.Returns(_rootFolder);
.Setup(s => s.GetBestRootFolder(It.IsAny<string>()))
.Returns(new RootFolder { Path = _rootFolder });
Mocker.GetMock<IArtistService>()
.Setup(s => s.GetArtists(It.IsAny<List<int>>()))
.Returns(new List<Artist>());
Mocker.GetMock<IMakeImportDecision>()
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<FilterFilesType>(), It.IsAny<bool>()))
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(new List<ImportDecision<LocalTrack>>());
Mocker.GetMock<IMediaFileService>()
@ -57,8 +60,8 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Returns(new List<TrackFile>());
Mocker.GetMock<IMediaFileService>()
.Setup(v => v.FilterUnchangedFiles(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<FilterFilesType>()))
.Returns((List<IFileInfo> files, Artist artist, FilterFilesType filter) => files);
.Setup(v => v.FilterUnchangedFiles(It.IsAny<List<IFileInfo>>(), It.IsAny<FilterFilesType>()))
.Returns((List<IFileInfo> files, FilterFilesType filter) => files);
}
private void GivenRootFolder(params string[] subfolders)
@ -112,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
[Test]
public void should_not_scan_if_root_folder_does_not_exist()
{
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
ExceptionVerification.ExpectedWarns(1);
@ -120,15 +123,18 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.FolderExists(_artist.Path), Times.Never());
Mocker.GetMock<IMediaFileTableCleanupService>()
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Never());
.Verify(v => v.Clean(It.IsAny<string>(), It.IsAny<List<string>>()), Times.Never());
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Never());
}
[Test]
public void should_not_scan_if_artist_root_folder_is_empty()
public void should_not_scan_if_root_folder_is_empty()
{
GivenRootFolder();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
ExceptionVerification.ExpectedWarns(1);
@ -136,72 +142,23 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Verify(v => v.FolderExists(_artist.Path), Times.Never());
Mocker.GetMock<IMediaFileTableCleanupService>()
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Never());
.Verify(v => v.Clean(It.IsAny<string>(), It.IsAny<List<string>>()), Times.Never());
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), _artist, FilterFilesType.Known, true), Times.Never());
.Verify(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Never());
}
[Test]
public void should_create_if_artist_folder_does_not_exist_but_create_folder_enabled()
public void should_clean_if_folder_does_not_exist()
{
GivenRootFolder(_otherArtistFolder);
Mocker.GetMock<IConfigService>()
.Setup(s => s.CreateEmptyArtistFolders)
.Returns(true);
Subject.Scan(_artist);
DiskProvider.FolderExists(_artist.Path).Should().BeTrue();
}
[Test]
public void should_not_create_if_artist_folder_does_not_exist_and_create_folder_disabled()
{
GivenRootFolder(_otherArtistFolder);
Mocker.GetMock<IConfigService>()
.Setup(s => s.CreateEmptyArtistFolders)
.Returns(false);
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
DiskProvider.FolderExists(_artist.Path).Should().BeFalse();
}
[Test]
public void should_clean_but_not_import_if_artist_folder_does_not_exist()
{
GivenRootFolder(_otherArtistFolder);
Subject.Scan(_artist);
DiskProvider.FolderExists(_artist.Path).Should().BeFalse();
Mocker.GetMock<IMediaFileTableCleanupService>()
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once());
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), _artist, FilterFilesType.Known, true), Times.Never());
}
[Test]
public void should_clean_but_not_import_if_artist_folder_does_not_exist_and_create_folder_enabled()
{
GivenRootFolder(_otherArtistFolder);
Mocker.GetMock<IConfigService>()
.Setup(s => s.CreateEmptyArtistFolders)
.Returns(true);
Subject.Scan(_artist);
Mocker.GetMock<IMediaFileTableCleanupService>()
.Verify(v => v.Clean(It.IsAny<Artist>(), It.IsAny<List<string>>()), Times.Once());
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), _artist, FilterFilesType.Known, true), Times.Never());
.Verify(v => v.Clean(It.IsAny<string>(), It.IsAny<List<string>>()), Times.Once());
}
[Test]
@ -215,10 +172,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 2), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -235,10 +192,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -253,10 +210,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -276,10 +233,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 2", "s02e02.flac"),
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 4), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 4), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -292,10 +249,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Album 1", ".t01.mp3")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -311,10 +268,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -331,10 +288,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -348,10 +305,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -365,10 +322,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -384,10 +341,10 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "Season 1", "s01e01.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 2), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 2), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
[Test]
@ -402,20 +359,20 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
Path.Combine(_artist.Path, "24 The Status Quo Combustion.flac")
});
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMakeImportDecision>()
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), _artist, FilterFilesType.Known, true), Times.Once());
.Verify(v => v.GetImportDecisions(It.Is<List<IFileInfo>>(l => l.Count == 1), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()), Times.Once());
}
private void GivenRejections()
{
Mocker.GetMock<IMakeImportDecision>()
.Setup(x => x.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<FilterFilesType>(), It.IsAny<bool>()))
.Returns((List<IFileInfo> fileList, Artist artist, FilterFilesType filter, bool includeExisting) =>
.Setup(x => x.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns((List<IFileInfo> fileList, IdentificationOverrides idOverrides, ImportDecisionMakerInfo idInfo, ImportDecisionMakerConfig idConfig) =>
fileList.Select(x => new LocalTrack
{
Artist = artist,
Artist = _artist,
Path = x.FullName,
Modified = x.LastWriteTimeUtc,
FileTrackInfo = new ParsedTrackInfo()
@ -437,7 +394,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
GivenKnownFiles(new List<string>());
GivenRejections();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.AddMany(It.Is<List<TrackFile>>(l => l.Select(t => t.Path).SequenceEqual(files))),
@ -457,7 +414,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
GivenKnownFiles(files.GetRange(1, 1));
GivenRejections();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.AddMany(It.Is<List<TrackFile>>(l => l.Select(t => t.Path).SequenceEqual(files.GetRange(0, 1)))),
@ -477,7 +434,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
GivenKnownFiles(files);
GivenRejections();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.AddMany(It.Is<List<TrackFile>>(l => l.Count == 0)),
@ -501,7 +458,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
GivenKnownFiles(files);
GivenRejections();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.Update(It.Is<List<TrackFile>>(l => l.Count == 0)),
@ -525,7 +482,7 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
GivenKnownFiles(files);
GivenRejections();
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.Update(It.Is<List<TrackFile>>(l => l.Count == 2)),
@ -556,14 +513,14 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests
.Build();
Mocker.GetMock<IMakeImportDecision>()
.Setup(x => x.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<FilterFilesType>(), It.IsAny<bool>()))
.Setup(x => x.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(new List<ImportDecision<LocalTrack>> { new ImportDecision<LocalTrack>(localTrack, new Rejection("Reject")) });
Subject.Scan(_artist);
Subject.Scan(new List<string> { _artist.Path });
Mocker.GetMock<IMediaFileService>()
.Verify(x => x.Update(It.Is<List<TrackFile>>(
l => l.Count == 1 &&
l => l.Count == 1 &&
l[0].Path == localTrack.Path &&
l[0].Modified == localTrack.Modified &&
l[0].Size == localTrack.Size &&

@ -84,7 +84,7 @@ namespace NzbDrone.Core.Test.MediaFiles
imported.Add(new ImportDecision<LocalTrack>(localTrack));
Mocker.GetMock<IMakeImportDecision>()
.Setup(s => s.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>(), null))
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(imported);
Mocker.GetMock<IImportApprovedTracks>()
@ -130,8 +130,8 @@ namespace NzbDrone.Core.Test.MediaFiles
Subject.ProcessRootFolder(DiskProvider.GetDirectoryInfo(_droneFactory));
Mocker.GetMock<IMakeImportDecision>()
.Verify(c => c.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>(), It.IsAny<ParsedTrackInfo>()),
Times.Never());
.Verify(c => c.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()),
Times.Never());
VerifyNoImport();
}
@ -181,7 +181,7 @@ namespace NzbDrone.Core.Test.MediaFiles
imported.Add(new ImportDecision<LocalTrack>(localTrack));
Mocker.GetMock<IMakeImportDecision>()
.Setup(s => s.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>(), null))
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(imported);
Mocker.GetMock<IImportApprovedTracks>()
@ -238,7 +238,7 @@ namespace NzbDrone.Core.Test.MediaFiles
imported.Add(new ImportDecision<LocalTrack>(localTrack));
Mocker.GetMock<IMakeImportDecision>()
.Setup(s => s.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>(), null))
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(imported);
Mocker.GetMock<IImportApprovedTracks>()
@ -278,7 +278,7 @@ namespace NzbDrone.Core.Test.MediaFiles
imported.Add(new ImportDecision<LocalTrack>(localTrack));
Mocker.GetMock<IMakeImportDecision>()
.Setup(s => s.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>(), null))
.Setup(v => v.GetImportDecisions(It.IsAny<List<IFileInfo>>(), It.IsAny<IdentificationOverrides>(), It.IsAny<ImportDecisionMakerInfo>(), It.IsAny<ImportDecisionMakerConfig>()))
.Returns(imported);
Mocker.GetMock<IImportApprovedTracks>()

@ -123,7 +123,6 @@ namespace NzbDrone.Core.Test.MediaFiles
{
VerifyData();
var firstReleaseFiles = Subject.GetFilesWithBasePath(dir.AsOsAgnostic());
VerifyEagerLoaded(firstReleaseFiles);
firstReleaseFiles.Should().HaveCount(2);
}

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

Loading…
Cancel
Save