From a16463eeb34ed000cf4857ae4154b4604a188741 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 3 Aug 2019 18:55:31 -0700 Subject: [PATCH] New: Artist folder hint when selecting a root folder while adding a new artist Closes #1214 Closes #1467 --- frontend/src/Artist/NoArtist.js | 5 +- .../Form/RootFolderSelectInputOption.css | 9 +++ .../Form/RootFolderSelectInputOption.css.d.ts | 2 + .../Form/RootFolderSelectInputOption.js | 28 +++++++++- .../RootFolderSelectInputSelectedValue.css | 14 ++++- ...ootFolderSelectInputSelectedValue.css.d.ts | 2 + .../RootFolderSelectInputSelectedValue.js | 23 +++++++- frontend/src/Search/AddNewItem.css | 8 ++- frontend/src/Search/AddNewItem.css.d.ts | 1 + frontend/src/Search/AddNewItem.js | 27 ++++++++- frontend/src/Search/AddNewItemConnector.js | 6 +- .../Search/Album/AddNewAlbumModalContent.js | 3 +- .../Album/AddNewAlbumModalContentConnector.js | 5 +- .../Search/Album/AddNewAlbumSearchResult.js | 1 + .../Search/Artist/AddNewArtistModalContent.js | 4 +- .../AddNewArtistModalContentConnector.js | 5 +- .../Search/Artist/AddNewArtistSearchResult.js | 3 + .../src/Search/Common/AddArtistOptionsForm.js | 13 +++++ src/Lidarr.Api.V1/Artist/ArtistController.cs | 8 ++- .../ArtistFolderAsRootFolderValidator.cs | 56 +++++++++++++++++++ .../Artist/ArtistLookupController.cs | 8 ++- src/Lidarr.Api.V1/Artist/ArtistResource.cs | 1 + .../RootFolders/RootFolderResource.cs | 4 +- src/Lidarr.Api.V1/Search/SearchController.cs | 19 +++++-- .../Extensions/PathExtensions.cs | 9 +++ src/NzbDrone.Core/Localization/Core/en.json | 3 + 26 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 src/Lidarr.Api.V1/Artist/ArtistFolderAsRootFolderValidator.cs diff --git a/frontend/src/Artist/NoArtist.js b/frontend/src/Artist/NoArtist.js index fffd8b85d..ec812fc84 100644 --- a/frontend/src/Artist/NoArtist.js +++ b/frontend/src/Artist/NoArtist.js @@ -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')} @@ -37,7 +38,7 @@ function NoArtist(props) { to="/add/search" kind={kinds.PRIMARY} > - Add New Artist + {translate('AddNewArtist')} diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css index f7a2759fd..b2021254a 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.css +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css @@ -13,6 +13,15 @@ } } +.value { + display: flex; +} + +.artistFolder { + flex: 0 0 auto; + color: var(--disabledColor); +} + .freeSpace { margin-left: 15px; color: var(--darkGray); diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts b/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts index 0c549f7a9..2c31fda05 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css.d.ts @@ -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; diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js index 929b9f342..0e7bb572c 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.js +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js @@ -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 ( @@ -26,7 +34,18 @@ function RootFolderSelectInputOption(props) { isMobile && styles.isMobile )} > -
{text}
+
+ {text} + + { + artistFolder && id !== 'addNew' ? +
+ {slashCharacter} + {artistFolder} +
: + null + } +
{ 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; diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css index 86107a624..d248416d2 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css @@ -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; diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts index 682ac91de..eb2ba85c7 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css.d.ts @@ -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; diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js index 0af2f61ae..3cd43b204 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js @@ -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 ( -
- {text} +
+
+ {text} +
+ + { + artistFolder ? +
+ {slashCharacter} + {artistFolder} +
: + null + }
{ @@ -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 }; diff --git a/frontend/src/Search/AddNewItem.css b/frontend/src/Search/AddNewItem.css index d587bfbb8..131f8ec5f 100644 --- a/frontend/src/Search/AddNewItem.css +++ b/frontend/src/Search/AddNewItem.css @@ -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; diff --git a/frontend/src/Search/AddNewItem.css.d.ts b/frontend/src/Search/AddNewItem.css.d.ts index 021428cb3..3d139bd36 100644 --- a/frontend/src/Search/AddNewItem.css.d.ts +++ b/frontend/src/Search/AddNewItem.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'clearLookupButton': string; 'helpText': string; 'message': string; + 'noArtistsText': string; 'noResults': string; 'searchContainer': string; 'searchIconContainer': string; diff --git a/frontend/src/Search/AddNewItem.js b/frontend/src/Search/AddNewItem.js index 9a159d714..5ec065149 100644 --- a/frontend/src/Search/AddNewItem.js +++ b/frontend/src/Search/AddNewItem.js @@ -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 :
{translate('ItsEasyToAddANewArtistJustStartTypingTheNameOfTheArtistYouWantToAdd')} @@ -191,6 +193,24 @@ class AddNewItem extends Component {
} + { + !term && !hasExistingArtists ? +
+
+ You haven't added any artists yet, do you want to add an existing library location (Root Folder) and update? +
+
+ +
+
: + null + } +
@@ -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 }; diff --git a/frontend/src/Search/AddNewItemConnector.js b/frontend/src/Search/AddNewItemConnector.js index d1064777d..4878d1a44 100644 --- a/frontend/src/Search/AddNewItemConnector.js +++ b/frontend/src/Search/AddNewItemConnector.js @@ -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 }; } ); diff --git a/frontend/src/Search/Album/AddNewAlbumModalContent.js b/frontend/src/Search/Album/AddNewAlbumModalContent.js index ee9501fa3..829b8d979 100644 --- a/frontend/src/Search/Album/AddNewAlbumModalContent.js +++ b/frontend/src/Search/Album/AddNewAlbumModalContent.js @@ -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 ( - Add new Album + {translate('AddNewAlbum')} diff --git a/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js b/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js index fa18374e6..dd7c03cab 100644 --- a/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js +++ b/frontend/src/Search/Album/AddNewAlbumModalContentConnector.js @@ -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 }; } diff --git a/frontend/src/Search/Album/AddNewAlbumSearchResult.js b/frontend/src/Search/Album/AddNewAlbumSearchResult.js index 35e352153..c82c58ecb 100644 --- a/frontend/src/Search/Album/AddNewAlbumSearchResult.js +++ b/frontend/src/Search/Album/AddNewAlbumSearchResult.js @@ -215,6 +215,7 @@ class AddNewAlbumSearchResult extends Component { disambiguation={disambiguation} artistName={artist.artistName} overview={overview} + folder={artist.folder} images={images} onModalClose={this.onAddAlbumModalClose} /> diff --git a/frontend/src/Search/Artist/AddNewArtistModalContent.js b/frontend/src/Search/Artist/AddNewArtistModalContent.js index ef3abb42b..9ad4908d6 100644 --- a/frontend/src/Search/Artist/AddNewArtistModalContent.js +++ b/frontend/src/Search/Artist/AddNewArtistModalContent.js @@ -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 ( - Add new Artist + {translate('AddNewArtist')} @@ -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 }; diff --git a/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js b/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js index acd168f0b..1b50869e5 100644 --- a/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js +++ b/frontend/src/Search/Artist/AddNewArtistModalContentConnector.js @@ -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 }; } diff --git a/frontend/src/Search/Artist/AddNewArtistSearchResult.js b/frontend/src/Search/Artist/AddNewArtistSearchResult.js index 75d72f38d..8ad5f6061 100644 --- a/frontend/src/Search/Artist/AddNewArtistSearchResult.js +++ b/frontend/src/Search/Artist/AddNewArtistSearchResult.js @@ -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 diff --git a/frontend/src/Search/Common/AddArtistOptionsForm.js b/frontend/src/Search/Common/AddArtistOptionsForm.js index dc2b621dd..bcbbfe795 100644 --- a/frontend/src/Search/Common/AddArtistOptionsForm.js +++ b/frontend/src/Search/Common/AddArtistOptionsForm.js @@ -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 { @@ -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 }; diff --git a/src/Lidarr.Api.V1/Artist/ArtistController.cs b/src/Lidarr.Api.V1/Artist/ArtistController.cs index 71efe3594..b3883cae9 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistController.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistController.cs @@ -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); diff --git a/src/Lidarr.Api.V1/Artist/ArtistFolderAsRootFolderValidator.cs b/src/Lidarr.Api.V1/Artist/ArtistFolderAsRootFolderValidator.cs new file mode 100644 index 000000000..d58ae235e --- /dev/null +++ b/src/Lidarr.Api.V1/Artist/ArtistFolderAsRootFolderValidator.cs @@ -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); + } + } +} diff --git a/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs b/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs index 598239ee9..27ff021a1 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs @@ -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; } } diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index 5797a3bfa..0c09f9825 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -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 Genres { get; set; } public string CleanName { get; set; } public string SortName { get; set; } diff --git a/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs b/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs index 3e82f3585..6684ce3dd 100644 --- a/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs +++ b/src/Lidarr.Api.V1/RootFolders/RootFolderResource.cs @@ -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, diff --git a/src/Lidarr.Api.V1/Search/SearchController.cs b/src/Lidarr.Api.V1/Search/SearchController.cs index 00315b884..9dc33066c 100644 --- a/src/Lidarr.Api.V1/Search/SearchController.cs +++ b/src/Lidarr.Api.V1/Search/SearchController.cs @@ -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 MapToResource(IEnumerable results) + private IEnumerable MapToResource(IEnumerable 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 { diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index 80c8ada78..0c60d2e65 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -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(":\\")) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 42d553a0d..aae88d762 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -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",