New: Don't require artist mapping

This commit is contained in:
ta264 2020-02-09 19:15:43 +00:00
parent 34b1d429be
commit a506125841
159 changed files with 2934 additions and 4208 deletions

View File

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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

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

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

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

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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
};

View File

@ -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
};
}
);

View File

@ -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}

View File

@ -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}>

View File

@ -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
};
}
);

View File

@ -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';

View File

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

View File

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

View File

@ -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>

View File

@ -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';

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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':

View File

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

View File

@ -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) => {

View File

@ -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;

View File

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

View File

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

View File

@ -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,

View File

@ -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}>

View File

@ -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}

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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() {

View File

@ -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() {

View File

@ -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>
);

View File

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

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { 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);

View File

@ -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)
}
};

View File

@ -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);

View File

@ -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,

View File

@ -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) {

View File

@ -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);

View File

@ -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);

View File

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

View File

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

View File

@ -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
};

View File

@ -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

View File

@ -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;

View File

@ -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'
}));
}
};
}

View File

@ -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();

View File

@ -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());

View File

@ -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
});
}

View File

@ -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);
}

View File

@ -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),

View File

@ -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(),

View File

@ -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)

View File

@ -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
};
}

View File

@ -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,

View File

@ -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,

View File

@ -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;
}
}
}

View File

@ -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();

View File

@ -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);
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);
Subject.Scan(new List<string> { _artist.Path });
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 &&

View File

@ -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>()

View File

@ -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