From dd09f31abb4dd3f699bcff0a47577075300c70ee Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sat, 3 Aug 2019 18:55:31 -0700 Subject: [PATCH] New: Series folder hint when selecting a root folder while adding a new series --- .../AddSeries/AddNewSeries/AddNewSeries.css | 8 +++- .../AddSeries/AddNewSeries/AddNewSeries.js | 31 ++++++++++++++-- .../AddNewSeries/AddNewSeriesConnector.js | 6 ++- .../AddNewSeries/AddNewSeriesModalContent.js | 13 +++++++ .../AddNewSeriesModalContentConnector.js | 5 ++- .../AddNewSeries/AddNewSeriesSearchResult.js | 3 ++ .../Components/Form/EnhancedSelectInput.js | 5 +++ .../Form/RootFolderSelectInputConnector.js | 5 +-- .../Form/RootFolderSelectInputOption.css | 9 +++++ .../Form/RootFolderSelectInputOption.js | 24 +++++++++++- .../RootFolderSelectInputSelectedValue.css | 12 +++++- .../RootFolderSelectInputSelectedValue.js | 21 ++++++++++- frontend/src/Series/NoSeries.js | 2 +- .../Extensions/PathExtensions.cs | 9 +++++ .../RootFolders/RootFolderResource.cs | 5 ++- .../SeriesFolderAsRootFolderValidator.cs | 37 +++++++++++++++++++ .../Series/SeriesLookupModule.cs | 11 ++++-- src/Sonarr.Api.V3/Series/SeriesModule.cs | 8 +++- src/Sonarr.Api.V3/Series/SeriesResource.cs | 1 + src/Sonarr.Api.V3/Sonarr.Api.V3.csproj | 1 + 20 files changed, 191 insertions(+), 25 deletions(-) create mode 100644 src/Sonarr.Api.V3/Series/SeriesFolderAsRootFolderValidator.cs diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css index 7c558d6d0..fa28540cd 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.css +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.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; } +.noSeriesText { + margin-top: 80px; + margin-bottom: 20px; +} + .noResults { margin-bottom: 10px; font-weight: 300; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js index 6936599a8..d185bca30 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeries.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import Button from 'Components/Link/Button'; import Link from 'Components/Link/Link'; import Icon from 'Components/Icon'; @@ -78,7 +78,8 @@ class AddNewSeries extends Component { render() { const { error, - items + items, + hasExistingSeries } = this.props; const term = this.state.term; @@ -155,13 +156,34 @@ class AddNewSeries extends Component { } { - !term && + term ? + null :
-
It's easy to add a new series, just start typing the name the series you want to add.
+
+ It's easy to add a new series, just start typing the name the series you want to add. +
You can also search using TVDB ID of a show. eg. tvdb:71663
} + { + !term && !hasExistingSeries ? +
+
+ You haven't added any series yet, do you want to import some or all of your series first? +
+
+ +
+
: + null + } +
@@ -176,6 +198,7 @@ AddNewSeries.propTypes = { isAdding: PropTypes.bool.isRequired, addError: PropTypes.object, items: PropTypes.arrayOf(PropTypes.object).isRequired, + hasExistingSeries: PropTypes.bool.isRequired, onSeriesLookupChange: PropTypes.func.isRequired, onClearSeriesLookup: PropTypes.func.isRequired }; diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js index 332e9322a..03283c256 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesConnector.js @@ -10,13 +10,15 @@ import AddNewSeries from './AddNewSeries'; function createMapStateToProps() { return createSelector( (state) => state.addSeries, + (state) => state.series.items.length, (state) => state.router.location, - (addSeries, location) => { + (addSeries, existingSeriesCount, location) => { const { params } = parseUrl(location.search); return { + ...addSeries, term: params.term, - ...addSeries + hasExistingSeries: existingSeriesCount > 0 }; } ); diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js index 800705a57..3d2b9f76a 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContent.js @@ -66,9 +66,11 @@ class AddNewSeriesModalContent extends Component { languageProfileId, seriesType, seasonFolder, + folder, tags, showLanguageProfile, isSmallScreen, + isWindows, onModalClose, onInputChange, ...otherProps @@ -115,6 +117,15 @@ class AddNewSeriesModalContent extends Component { @@ -260,9 +271,11 @@ AddNewSeriesModalContent.propTypes = { languageProfileId: PropTypes.object, seriesType: PropTypes.object.isRequired, seasonFolder: PropTypes.object.isRequired, + folder: PropTypes.string.isRequired, tags: PropTypes.object.isRequired, showLanguageProfile: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired, + isWindows: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired, onAddSeriesPress: PropTypes.func.isRequired diff --git a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js index dc351933e..df7c3d5bd 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesModalContentConnector.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector'; import selectSettings from 'Store/Selectors/selectSettings'; import AddNewSeriesModalContent from './AddNewSeriesModalContent'; @@ -12,7 +13,8 @@ function createMapStateToProps() { (state) => state.addSeries, (state) => state.settings.languageProfiles, createDimensionsSelector(), - (addSeriesState, languageProfiles, dimensions) => { + createSystemStatusSelector(), + (addSeriesState, languageProfiles, 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/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js index f684ca35b..dd502e104 100644 --- a/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js +++ b/frontend/src/AddSeries/AddNewSeries/AddNewSeriesSearchResult.js @@ -53,6 +53,7 @@ class AddNewSeriesSearchResult extends Component { overview, statistics, ratings, + folder, images, isExistingSeries, isSmallScreen @@ -160,6 +161,7 @@ class AddNewSeriesSearchResult extends Component { title={title} year={year} overview={overview} + folder={folder} images={images} onModalClose={this.onAddSeriesModalClose} /> @@ -178,6 +180,7 @@ AddNewSeriesSearchResult.propTypes = { overview: PropTypes.string, statistics: PropTypes.object.isRequired, ratings: PropTypes.object.isRequired, + folder: PropTypes.string.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, isExistingSeries: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired diff --git a/frontend/src/Components/Form/EnhancedSelectInput.js b/frontend/src/Components/Form/EnhancedSelectInput.js index 80ee78e81..6c50c1c82 100644 --- a/frontend/src/Components/Form/EnhancedSelectInput.js +++ b/frontend/src/Components/Form/EnhancedSelectInput.js @@ -262,6 +262,7 @@ class EnhancedSelectInput extends Component { isDisabled, hasError, hasWarning, + valueOptions, selectedValueOptions, selectedValueComponent: SelectedValueComponent, optionComponent: OptionComponent @@ -363,6 +364,7 @@ class EnhancedSelectInput extends Component { key={v.key} id={v.key} isSelected={index === selectedIndex} + {...valueOptions} {...v} isMobile={false} onSelect={this.onSelect} @@ -404,6 +406,7 @@ class EnhancedSelectInput extends Component { key={v.key} id={v.key} isSelected={index === selectedIndex} + {...valueOptions} {...v} isMobile={true} onSelect={this.onSelect} @@ -431,6 +434,7 @@ EnhancedSelectInput.propTypes = { isDisabled: PropTypes.bool, hasError: PropTypes.bool, hasWarning: PropTypes.bool, + valueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, optionComponent: PropTypes.elementType, @@ -441,6 +445,7 @@ EnhancedSelectInput.defaultProps = { className: styles.enhancedSelect, disabledClassName: styles.isDisabled, isDisabled: false, + valueOptions: {}, selectedValueOptions: {}, selectedValueComponent: HintedSelectInputSelectedValue, optionComponent: HintedSelectInputOption diff --git a/frontend/src/Components/Form/RootFolderSelectInputConnector.js b/frontend/src/Components/Form/RootFolderSelectInputConnector.js index b3dfcbd20..b76501dc1 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputConnector.js +++ b/frontend/src/Components/Form/RootFolderSelectInputConnector.js @@ -1,4 +1,3 @@ -import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; @@ -13,7 +12,7 @@ function createMapStateToProps() { (state) => state.rootFolders, (state, { includeNoChange }) => includeNoChange, (rootFolders, includeNoChange) => { - const values = _.map(rootFolders.items, (rootFolder) => { + const values = rootFolders.items.map((rootFolder) => { return { key: rootFolder.path, value: rootFolder.path, @@ -85,7 +84,7 @@ class RootFolderSelectInputConnector extends Component { onChange } = this.props; - if (!value || !_.some(values, (v) => v.key === value) || value === ADD_NEW_KEY) { + if (!value || !values.some((v) => v.key === value) || value === ADD_NEW_KEY) { const defaultValue = values[0]; if (defaultValue.key === ADD_NEW_KEY) { diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.css b/frontend/src/Components/Form/RootFolderSelectInputOption.css index d8b44fcad..0bad54366 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.css +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.css @@ -13,6 +13,15 @@ } } +.value { + display: flex; +} + +.seriesFolder { + flex: 0 0 auto; + color: $disabledColor; +} + .freeSpace { margin-left: 15px; color: $darkGray; diff --git a/frontend/src/Components/Form/RootFolderSelectInputOption.js b/frontend/src/Components/Form/RootFolderSelectInputOption.js index a4db9cd82..c3065c6f1 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputOption.js +++ b/frontend/src/Components/Form/RootFolderSelectInputOption.js @@ -7,14 +7,20 @@ import styles from './RootFolderSelectInputOption.css'; function RootFolderSelectInputOption(props) { const { + id, value, freeSpace, + seriesFolder, isMobile, + isWindows, ...otherProps } = props; + const slashCharacter = isWindows ? '\\' : '/'; + return ( @@ -23,7 +29,18 @@ function RootFolderSelectInputOption(props) { isMobile && styles.isMobile )} > -
{value}
+
+ {value} + + { + seriesFolder && id !== 'addNew' ? +
+ {slashCharacter} + {seriesFolder} +
: + null + } +
{ freeSpace != null && @@ -37,9 +54,12 @@ function RootFolderSelectInputOption(props) { } RootFolderSelectInputOption.propTypes = { + id: PropTypes.string.isRequired, value: PropTypes.string.isRequired, freeSpace: PropTypes.number, - isMobile: PropTypes.bool.isRequired + seriesFolder: 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 6b0cf9e4f..1353adfa3 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.css @@ -7,10 +7,20 @@ overflow: hidden; } +.pathContainer { + @add-mixin truncate; + display: flex; + flex: 1 0 0; +} + .path { @add-mixin truncate; + flex: 0 1 auto; +} - flex: 1 0 0; +.seriesFolder { + flex: 0 1 auto; + color: $disabledColor; } .freeSpace { diff --git a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js index ffd769254..69b1453f3 100644 --- a/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js +++ b/frontend/src/Components/Form/RootFolderSelectInputSelectedValue.js @@ -8,17 +8,32 @@ function RootFolderSelectInputSelectedValue(props) { const { value, freeSpace, + seriesFolder, includeFreeSpace, + isWindows, ...otherProps } = props; + const slashCharacter = isWindows ? '\\' : '/'; + return ( -
- {value} +
+
+ {value} +
+ + { + seriesFolder ? +
+ {slashCharacter} + {seriesFolder} +
: + null + }
{ @@ -34,6 +49,8 @@ function RootFolderSelectInputSelectedValue(props) { RootFolderSelectInputSelectedValue.propTypes = { value: PropTypes.string, freeSpace: PropTypes.number, + seriesFolder: PropTypes.string, + isWindows: PropTypes.bool, includeFreeSpace: PropTypes.bool.isRequired }; diff --git a/frontend/src/Series/NoSeries.js b/frontend/src/Series/NoSeries.js index cfcbb53ab..0e9a1d8ce 100644 --- a/frontend/src/Series/NoSeries.js +++ b/frontend/src/Series/NoSeries.js @@ -20,7 +20,7 @@ function NoSeries(props) { return (
- No series found, to get started you'll want to add a new series or import some existing ones. + No series found, to get started you'll want to import your existing series or add a new series.
diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index b79f5e86b..4e3b4537c 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -81,6 +81,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/Sonarr.Api.V3/RootFolders/RootFolderResource.cs b/src/Sonarr.Api.V3/RootFolders/RootFolderResource.cs index dba574686..e85cdc99d 100644 --- a/src/Sonarr.Api.V3/RootFolders/RootFolderResource.cs +++ b/src/Sonarr.Api.V3/RootFolders/RootFolderResource.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Common.Extensions; using NzbDrone.Core.RootFolders; using Sonarr.Http.REST; @@ -23,7 +24,7 @@ namespace Sonarr.Api.V3.RootFolders { Id = model.Id, - Path = model.Path, + Path = model.Path.GetCleanPath(), FreeSpace = model.FreeSpace, UnmappedFolders = model.UnmappedFolders }; @@ -37,7 +38,7 @@ namespace Sonarr.Api.V3.RootFolders { Id = resource.Id, - Path = resource.Path, + Path = resource.Path //FreeSpace //UnmappedFolders }; diff --git a/src/Sonarr.Api.V3/Series/SeriesFolderAsRootFolderValidator.cs b/src/Sonarr.Api.V3/Series/SeriesFolderAsRootFolderValidator.cs new file mode 100644 index 000000000..82413e356 --- /dev/null +++ b/src/Sonarr.Api.V3/Series/SeriesFolderAsRootFolderValidator.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using FluentValidation.Validators; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Organizer; + +namespace Sonarr.Api.V3.Series +{ + public class SeriesFolderAsRootFolderValidator : PropertyValidator + { + private readonly IBuildFileNames _fileNameBuilder; + + public SeriesFolderAsRootFolderValidator(IBuildFileNames fileNameBuilder) + : base("Root folder path contains series folder") + { + _fileNameBuilder = fileNameBuilder; + } + + protected override bool IsValid(PropertyValidatorContext context) + { + if (context.PropertyValue == null) return true; + + var seriesResource = context.Instance as SeriesResource; + + if (seriesResource == null) return true; + + var rootFolderPath = context.PropertyValue.ToString(); + var rootFolder = new DirectoryInfo(rootFolderPath).Name; + var series = seriesResource.ToModel(); + var seriesFolder = _fileNameBuilder.GetSeriesFolder(series); + + if (seriesFolder == rootFolder) return false; + + return seriesFolder.LevenshteinDistance(rootFolder) <= Math.Max(1, seriesFolder.Length * 0.2); + } + } +} diff --git a/src/Sonarr.Api.V3/Series/SeriesLookupModule.cs b/src/Sonarr.Api.V3/Series/SeriesLookupModule.cs index df62b052b..e3e6612d9 100644 --- a/src/Sonarr.Api.V3/Series/SeriesLookupModule.cs +++ b/src/Sonarr.Api.V3/Series/SeriesLookupModule.cs @@ -3,6 +3,7 @@ using System.Linq; using Nancy; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Organizer; using NzbDrone.Core.SeriesStats; using Sonarr.Http; using Sonarr.Http.Extensions; @@ -12,33 +13,35 @@ namespace Sonarr.Api.V3.Series public class SeriesLookupModule : SonarrRestModule { private readonly ISearchForNewSeries _searchProxy; + private readonly IBuildFileNames _fileNameBuilder; - public SeriesLookupModule(ISearchForNewSeries searchProxy) + public SeriesLookupModule(ISearchForNewSeries searchProxy, IBuildFileNames fileNameBuilder) : base("/series/lookup") { _searchProxy = searchProxy; + _fileNameBuilder = fileNameBuilder; Get["/"] = x => Search(); } - private Response Search() { var tvDbResults = _searchProxy.SearchForNewSeries((string)Request.Query.term); return MapToResource(tvDbResults).AsResponse(); } - - private static IEnumerable MapToResource(IEnumerable series) + private IEnumerable MapToResource(IEnumerable series) { foreach (var currentSeries in series) { var resource = currentSeries.ToResource(); var poster = currentSeries.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); + if (poster != null) { resource.RemotePoster = poster.Url; } + resource.Folder = _fileNameBuilder.GetSeriesFolder(currentSeries); resource.Statistics = new SeriesStatistics().ToResource(resource.Seasons); yield return resource; diff --git a/src/Sonarr.Api.V3/Series/SeriesModule.cs b/src/Sonarr.Api.V3/Series/SeriesModule.cs index 016a6dd53..5956fc5ab 100644 --- a/src/Sonarr.Api.V3/Series/SeriesModule.cs +++ b/src/Sonarr.Api.V3/Series/SeriesModule.cs @@ -55,7 +55,8 @@ namespace Sonarr.Api.V3.Series SeriesAncestorValidator seriesAncestorValidator, SystemFolderValidator systemFolderValidator, ProfileExistsValidator profileExistsValidator, - LanguageProfileExistsValidator languageProfileExistsValidator + LanguageProfileExistsValidator languageProfileExistsValidator, + SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator ) : base(signalRBroadcaster) { @@ -90,7 +91,10 @@ namespace Sonarr.Api.V3.Series SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator); 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(seriesFolderAsRootFolderValidator) + .When(s => s.Path.IsNullOrWhiteSpace()); PostValidator.RuleFor(s => s.Title).NotEmpty(); PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator); diff --git a/src/Sonarr.Api.V3/Series/SeriesResource.cs b/src/Sonarr.Api.V3/Series/SeriesResource.cs index 40a8ff6b3..8a8701d05 100644 --- a/src/Sonarr.Api.V3/Series/SeriesResource.cs +++ b/src/Sonarr.Api.V3/Series/SeriesResource.cs @@ -56,6 +56,7 @@ namespace Sonarr.Api.V3.Series public string ImdbId { get; set; } public string TitleSlug { get; set; } public string RootFolderPath { get; set; } + public string Folder { get; set; } public string Certification { get; set; } public List Genres { get; set; } public HashSet Tags { get; set; } diff --git a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj index 5f5f99b9a..deec8c714 100644 --- a/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj +++ b/src/Sonarr.Api.V3/Sonarr.Api.V3.csproj @@ -160,6 +160,7 @@ +