New: Artist folder hint when selecting a root folder while adding a new artist

Closes #1214
Closes #1467
pull/4065/head
Mark McDowall 5 years ago committed by Bogdan
parent 86130751c2
commit a16463eeb3

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './NoArtist.css';
function NoArtist(props) {
@ -28,7 +29,7 @@ function NoArtist(props) {
to="/settings/mediamanagement"
kind={kinds.PRIMARY}
>
Add Root Folder
{translate('AddRootFolder')}
</Button>
</div>
@ -37,7 +38,7 @@ function NoArtist(props) {
to="/add/search"
kind={kinds.PRIMARY}
>
Add New Artist
{translate('AddNewArtist')}
</Button>
</div>
</div>

@ -13,6 +13,15 @@
}
}
.value {
display: flex;
}
.artistFolder {
flex: 0 0 auto;
color: var(--disabledColor);
}
.freeSpace {
margin-left: 15px;
color: var(--darkGray);

@ -1,9 +1,11 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'artistFolder': string;
'freeSpace': string;
'isMobile': string;
'optionText': string;
'value': string;
}
export const cssExports: CssExports;
export default cssExports;

@ -7,17 +7,25 @@ import styles from './RootFolderSelectInputOption.css';
function RootFolderSelectInputOption(props) {
const {
id,
value,
name,
freeSpace,
artistFolder,
isMobile,
isWindows,
...otherProps
} = props;
const text = value === '' ? name : `${name} [${value}]`;
const slashCharacter = isWindows ? '\\' : '/';
const text = value === '' ? name : `[${name}] ${value}`;
console.debug(props);
return (
<EnhancedSelectInputOption
id={id}
isMobile={isMobile}
{...otherProps}
>
@ -26,7 +34,18 @@ function RootFolderSelectInputOption(props) {
isMobile && styles.isMobile
)}
>
<div>{text}</div>
<div className={styles.value}>
{text}
{
artistFolder && id !== 'addNew' ?
<div className={styles.artistFolder}>
{slashCharacter}
{artistFolder}
</div> :
null
}
</div>
{
freeSpace != null &&
@ -40,10 +59,13 @@ function RootFolderSelectInputOption(props) {
}
RootFolderSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
isMobile: PropTypes.bool.isRequired
artistFolder: PropTypes.string,
isMobile: PropTypes.bool.isRequired,
isWindows: PropTypes.bool
};
export default RootFolderSelectInputOption;

@ -7,12 +7,22 @@
overflow: hidden;
}
.path {
.pathContainer {
@add-mixin truncate;
display: flex;
flex: 1 0 0;
}
.path {
flex: 0 1 auto;
}
.artistFolder {
@add-mixin truncate;
flex: 0 1 auto;
color: var(--disabledColor);
}
.freeSpace {
flex: 0 0 auto;
margin-left: 15px;

@ -1,8 +1,10 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'artistFolder': string;
'freeSpace': string;
'path': string;
'pathContainer': string;
'selectedValue': string;
}
export const cssExports: CssExports;

@ -9,19 +9,34 @@ function RootFolderSelectInputSelectedValue(props) {
name,
value,
freeSpace,
artistFolder,
includeFreeSpace,
isWindows,
...otherProps
} = props;
const text = value === '' ? name : `${name} [${value}]`;
const slashCharacter = isWindows ? '\\' : '/';
const text = value === '' ? name : `[${name}] ${value}`;
return (
<EnhancedSelectInputSelectedValue
className={styles.selectedValue}
{...otherProps}
>
<div className={styles.path}>
{text}
<div className={styles.pathContainer}>
<div className={styles.path}>
{text}
</div>
{
artistFolder ?
<div className={styles.artistFolder}>
{slashCharacter}
{artistFolder}
</div> :
null
}
</div>
{
@ -38,6 +53,8 @@ RootFolderSelectInputSelectedValue.propTypes = {
name: PropTypes.string,
value: PropTypes.string,
freeSpace: PropTypes.number,
artistFolder: PropTypes.string,
isWindows: PropTypes.bool,
includeFreeSpace: PropTypes.bool.isRequired
};

@ -35,14 +35,20 @@
.message {
margin-top: 30px;
text-align: center;
font-weight: 300;
font-size: $largeFontSize;
}
.helpText {
margin-bottom: 10px;
font-weight: 300;
font-size: 24px;
}
.noArtistsText {
margin-top: 80px;
margin-bottom: 20px;
}
.noResults {
margin-bottom: 10px;
font-weight: 300;

@ -4,6 +4,7 @@ interface CssExports {
'clearLookupButton': string;
'helpText': string;
'message': string;
'noArtistsText': string;
'noResults': string;
'searchContainer': string;
'searchIconContainer': string;

@ -7,7 +7,7 @@ import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { icons } from 'Helpers/Props';
import { icons, kinds } from 'Helpers/Props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import AddNewAlbumSearchResultConnector from './Album/AddNewAlbumSearchResultConnector';
@ -81,7 +81,8 @@ class AddNewItem extends Component {
render() {
const {
error,
items
items,
hasExistingArtists
} = this.props;
const term = this.state.term;
@ -178,7 +179,8 @@ class AddNewItem extends Component {
}
{
!term &&
term ?
null :
<div className={styles.message}>
<div className={styles.helpText}>
{translate('ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd')}
@ -191,6 +193,24 @@ class AddNewItem extends Component {
</div>
}
{
!term && !hasExistingArtists ?
<div className={styles.message}>
<div className={styles.noArtistsText}>
You haven't added any artists yet, do you want to add an existing library location (Root Folder) and update?
</div>
<div>
<Button
to="/settings/mediamanagement"
kind={kinds.PRIMARY}
>
Add Root Folder
</Button>
</div>
</div> :
null
}
<div />
</PageContentBody>
</PageContent>
@ -205,6 +225,7 @@ AddNewItem.propTypes = {
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingArtists: PropTypes.bool.isRequired,
onSearchChange: PropTypes.func.isRequired,
onClearSearch: PropTypes.func.isRequired
};

@ -10,13 +10,15 @@ import AddNewItem from './AddNewItem';
function createMapStateToProps() {
return createSelector(
(state) => state.search,
(state) => state.artist.items.length,
(state) => state.router.location,
(search, location) => {
(search, existingArtistsCount, location) => {
const { params } = parseUrl(location.search);
return {
...search,
term: params.term,
...search
hasExistingArtists: existingArtistsCount > 0
};
}
);

@ -11,6 +11,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import AddArtistOptionsForm from '../Common/AddArtistOptionsForm.js';
import styles from './AddNewAlbumModalContent.css';
import translate from 'Utilities/String/translate';
class AddNewAlbumModalContent extends Component {
@ -56,7 +57,7 @@ class AddNewAlbumModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add new Album
{translate('AddNewAlbum')}
</ModalHeader>
<ModalBody>

@ -5,6 +5,7 @@ import { createSelector } from 'reselect';
import { metadataProfileNames } from 'Helpers/Props';
import { addAlbum, setAddDefault } from 'Store/Actions/searchActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewAlbumModalContent from './AddNewAlbumModalContent';
@ -14,7 +15,8 @@ function createMapStateToProps() {
(state) => state.search,
(state) => state.settings.metadataProfiles,
createDimensionsSelector(),
(isExistingArtist, searchState, metadataProfiles, dimensions) => {
createSystemStatusSelector(),
(isExistingArtist, searchState, metadataProfiles, dimensions, systemStatus) => {
const {
isAdding,
addError,
@ -38,6 +40,7 @@ function createMapStateToProps() {
validationErrors,
validationWarnings,
noneMetadataProfileId: noneProfile.id,
isWindows: systemStatus.isWindows,
...settings
};
}

@ -215,6 +215,7 @@ class AddNewAlbumSearchResult extends Component {
disambiguation={disambiguation}
artistName={artist.artistName}
overview={overview}
folder={artist.folder}
images={images}
onModalClose={this.onAddAlbumModalClose}
/>

@ -11,6 +11,7 @@ import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import AddArtistOptionsForm from '../Common/AddArtistOptionsForm.js';
import styles from './AddNewArtistModalContent.css';
import translate from 'Utilities/String/translate';
class AddNewArtistModalContent extends Component {
@ -54,7 +55,7 @@ class AddNewArtistModalContent extends Component {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Add new Artist
{translate('AddNewArtist')}
</ModalHeader>
<ModalBody>
@ -139,6 +140,7 @@ AddNewArtistModalContent.propTypes = {
isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object,
isSmallScreen: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired,
onAddArtistPress: PropTypes.func.isRequired
};

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { addArtist, setAddDefault } from 'Store/Actions/searchActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings';
import AddNewArtistModalContent from './AddNewArtistModalContent';
@ -12,7 +13,8 @@ function createMapStateToProps() {
(state) => state.search,
(state) => state.settings.metadataProfiles,
createDimensionsSelector(),
(searchState, metadataProfiles, dimensions) => {
createSystemStatusSelector(),
(searchState, metadataProfiles, dimensions, systemStatus) => {
const {
isAdding,
addError,
@ -32,6 +34,7 @@ function createMapStateToProps() {
isSmallScreen: dimensions.isSmallScreen,
validationErrors,
validationWarnings,
isWindows: systemStatus.isWindows,
...settings
};
}

@ -77,6 +77,7 @@ class AddNewArtistSearchResult extends Component {
status,
overview,
ratings,
folder,
images,
isExistingArtist,
isSmallScreen
@ -208,6 +209,7 @@ class AddNewArtistSearchResult extends Component {
disambiguation={disambiguation}
year={year}
overview={overview}
folder={folder}
images={images}
onModalClose={this.onAddArtistModalClose}
/>
@ -225,6 +227,7 @@ AddNewArtistSearchResult.propTypes = {
status: PropTypes.string.isRequired,
overview: PropTypes.string,
ratings: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingArtist: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired

@ -38,7 +38,9 @@ class AddArtistOptionsForm extends Component {
metadataProfileId,
includeNoneMetadataProfile,
showMetadataProfile,
folder,
tags,
isWindows,
onInputChange,
...otherProps
} = this.props;
@ -53,6 +55,15 @@ class AddArtistOptionsForm extends Component {
<FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath"
valueOptions={{
artistFolder: folder,
isWindows
}}
selectedValueOptions={{
artistFolder: folder,
isWindows
}}
helpText={translate('AddNewArtistRootFolderHelpText', { folder })}
onChange={onInputChange}
{...rootFolderPath}
/>
@ -176,7 +187,9 @@ AddArtistOptionsForm.propTypes = {
metadataProfileId: PropTypes.object,
showMetadataProfile: PropTypes.bool.isRequired,
includeNoneMetadataProfile: PropTypes.bool.isRequired,
folder: PropTypes.string.isRequired,
tags: PropTypes.object.isRequired,
isWindows: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired
};

@ -61,7 +61,8 @@ namespace Lidarr.Api.V1.Artist
ArtistAncestorValidator artistAncestorValidator,
SystemFolderValidator systemFolderValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
MetadataProfileExistsValidator metadataProfileExistsValidator,
ArtistFolderAsRootFolderValidator artistFolderAsRootFolderValidator)
: base(signalRBroadcaster)
{
_artistService = artistService;
@ -91,7 +92,10 @@ namespace Lidarr.Api.V1.Artist
SharedValidator.RuleFor(s => s.MetadataProfileId).SetValidator(metadataProfileExistsValidator);
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath).IsValidPath().When(s => s.Path.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath)
.IsValidPath()
.SetValidator(artistFolderAsRootFolderValidator)
.When(s => s.Path.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.ArtistName).NotEmpty();
PostValidator.RuleFor(s => s.ForeignArtistId).NotEmpty().SetValidator(artistExistsValidator);

@ -0,0 +1,56 @@
using System;
using System.IO;
using FluentValidation.Validators;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Organizer;
namespace Lidarr.Api.V1.Artist
{
public class ArtistFolderAsRootFolderValidator : PropertyValidator
{
private readonly IBuildFileNames _fileNameBuilder;
public ArtistFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder)
{
_fileNameBuilder = fileNameBuilder;
}
protected override string GetDefaultMessageTemplate() => "Root folder path '{rootFolderPath}' contains artist folder '{artistFolder}'";
protected override bool IsValid(PropertyValidatorContext context)
{
if (context.PropertyValue == null)
{
return true;
}
if (context.InstanceToValidate is not ArtistResource artistResource)
{
return true;
}
var rootFolderPath = context.PropertyValue.ToString();
if (rootFolderPath.IsNullOrWhiteSpace())
{
return true;
}
var rootFolder = new DirectoryInfo(rootFolderPath!).Name;
var artist = artistResource.ToModel();
var artistFolder = _fileNameBuilder.GetArtistFolder(artist);
context.MessageFormatter.AppendArgument("rootFolderPath", rootFolderPath);
context.MessageFormatter.AppendArgument("artistFolder", artistFolder);
if (artistFolder == rootFolder)
{
return false;
}
var distance = artistFolder.LevenshteinDistance(rootFolder);
return distance >= Math.Max(1, artistFolder.Length * 0.2);
}
}
}

@ -4,6 +4,7 @@ using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Organizer;
namespace Lidarr.Api.V1.Artist
{
@ -11,11 +12,13 @@ namespace Lidarr.Api.V1.Artist
public class ArtistLookupController : Controller
{
private readonly ISearchForNewArtist _searchProxy;
private readonly IBuildFileNames _fileNameBuilder;
private readonly IMapCoversToLocal _coverMapper;
public ArtistLookupController(ISearchForNewArtist searchProxy, IMapCoversToLocal coverMapper)
public ArtistLookupController(ISearchForNewArtist searchProxy, IBuildFileNames fileNameBuilder, IMapCoversToLocal coverMapper)
{
_searchProxy = searchProxy;
_fileNameBuilder = fileNameBuilder;
_coverMapper = coverMapper;
}
@ -35,11 +38,14 @@ namespace Lidarr.Api.V1.Artist
_coverMapper.ConvertToLocalUrls(resource.Id, MediaCoverEntity.Artist, resource.Images);
var poster = currentArtist.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
if (poster != null)
{
resource.RemotePoster = poster.RemoteUrl;
}
resource.Folder = _fileNameBuilder.GetArtistFolder(currentArtist);
yield return resource;
}
}

@ -49,6 +49,7 @@ namespace Lidarr.Api.V1.Artist
public NewItemMonitorTypes MonitorNewItems { get; set; }
public string RootFolderPath { get; set; }
public string Folder { get; set; }
public List<string> Genres { get; set; }
public string CleanName { get; set; }
public string SortName { get; set; }

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Music;
using NzbDrone.Core.RootFolders;
@ -35,7 +36,8 @@ namespace Lidarr.Api.V1.RootFolders
Id = model.Id,
Name = model.Name,
Path = model.Path,
Path = model.Path.GetCleanPath(),
DefaultMetadataProfileId = model.DefaultMetadataProfileId,
DefaultQualityProfileId = model.DefaultQualityProfileId,
DefaultMonitorOption = model.DefaultMonitorOption,

@ -7,6 +7,7 @@ using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Organizer;
namespace Lidarr.Api.V1.Search
{
@ -14,10 +15,12 @@ namespace Lidarr.Api.V1.Search
public class SearchController : Controller
{
private readonly ISearchForNewEntity _searchProxy;
private readonly IBuildFileNames _fileNameBuilder;
public SearchController(ISearchForNewEntity searchProxy)
public SearchController(ISearchForNewEntity searchProxy, IBuildFileNames fileNameBuilder)
{
_searchProxy = searchProxy;
_fileNameBuilder = fileNameBuilder;
}
[HttpGet]
@ -27,7 +30,7 @@ namespace Lidarr.Api.V1.Search
return MapToResource(searchResults).ToList();
}
private static IEnumerable<SearchResource> MapToResource(IEnumerable<object> results)
private IEnumerable<SearchResource> MapToResource(IEnumerable<object> results)
{
var id = 1;
foreach (var result in results)
@ -35,29 +38,33 @@ namespace Lidarr.Api.V1.Search
var resource = new SearchResource();
resource.Id = id++;
if (result is NzbDrone.Core.Music.Artist)
if (result is NzbDrone.Core.Music.Artist artist)
{
var artist = (NzbDrone.Core.Music.Artist)result;
resource.Artist = artist.ToResource();
resource.ForeignId = artist.ForeignArtistId;
var poster = artist.Metadata.Value.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
if (poster != null)
{
resource.Artist.RemotePoster = poster.Url;
}
resource.Artist.Folder = _fileNameBuilder.GetArtistFolder(artist);
}
else if (result is NzbDrone.Core.Music.Album)
else if (result is NzbDrone.Core.Music.Album album)
{
var album = (NzbDrone.Core.Music.Album)result;
resource.Album = album.ToResource();
resource.ForeignId = album.ForeignAlbumId;
var cover = album.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Cover);
if (cover != null)
{
resource.Album.RemoteCover = cover.Url;
}
resource.Album.Artist.Folder = _fileNameBuilder.GetArtistFolder(album.Artist);
}
else
{

@ -107,6 +107,15 @@ namespace NzbDrone.Common.Extensions
return Directory.GetParent(cleanPath)?.FullName;
}
public static string GetCleanPath(this string path)
{
var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(path, "")
: path.TrimEnd(Path.DirectorySeparatorChar);
return cleanPath;
}
public static bool IsParentPath(this string parentPath, string childPath)
{
if (parentPath != "/" && !parentPath.EndsWith(":\\"))

@ -24,6 +24,9 @@
"AddMetadataProfile": "Add Metadata Profile",
"AddMissing": "Add missing",
"AddNew": "Add New",
"AddNewAlbum": "Add New Album",
"AddNewArtist": "Add New Artist",
"AddNewArtistRootFolderHelpText": "'{folder}' subfolder will be created automatically",
"AddNewItem": "Add New Item",
"AddQualityProfile": "Add Quality Profile",
"AddReleaseProfile": "Add Release Profile",

Loading…
Cancel
Save