New: Series folder hint when selecting a root folder while adding a new series

This commit is contained in:
Mark McDowall 2019-08-03 18:55:31 -07:00
parent 1da20da3ff
commit dd09f31abb
20 changed files with 191 additions and 25 deletions

View File

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

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { icons } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
@ -78,7 +78,8 @@ class AddNewSeries extends Component {
render() { render() {
const { const {
error, error,
items items,
hasExistingSeries
} = this.props; } = this.props;
const term = this.state.term; const term = this.state.term;
@ -155,13 +156,34 @@ class AddNewSeries extends Component {
} }
{ {
!term && term ?
null :
<div className={styles.message}> <div className={styles.message}>
<div className={styles.helpText}>It's easy to add a new series, just start typing the name the series you want to add.</div> <div className={styles.helpText}>
It's easy to add a new series, just start typing the name the series you want to add.
</div>
<div>You can also search using TVDB ID of a show. eg. tvdb:71663</div> <div>You can also search using TVDB ID of a show. eg. tvdb:71663</div>
</div> </div>
} }
{
!term && !hasExistingSeries ?
<div className={styles.message}>
<div className={styles.noSeriesText}>
You haven't added any series yet, do you want to import some or all of your series first?
</div>
<div>
<Button
to="/add/import"
kind={kinds.PRIMARY}
>
Import Existing Series
</Button>
</div>
</div> :
null
}
<div /> <div />
</PageContentBodyConnector> </PageContentBodyConnector>
</PageContent> </PageContent>
@ -176,6 +198,7 @@ AddNewSeries.propTypes = {
isAdding: PropTypes.bool.isRequired, isAdding: PropTypes.bool.isRequired,
addError: PropTypes.object, addError: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingSeries: PropTypes.bool.isRequired,
onSeriesLookupChange: PropTypes.func.isRequired, onSeriesLookupChange: PropTypes.func.isRequired,
onClearSeriesLookup: PropTypes.func.isRequired onClearSeriesLookup: PropTypes.func.isRequired
}; };

View File

@ -10,13 +10,15 @@ import AddNewSeries from './AddNewSeries';
function createMapStateToProps() { function createMapStateToProps() {
return createSelector( return createSelector(
(state) => state.addSeries, (state) => state.addSeries,
(state) => state.series.items.length,
(state) => state.router.location, (state) => state.router.location,
(addSeries, location) => { (addSeries, existingSeriesCount, location) => {
const { params } = parseUrl(location.search); const { params } = parseUrl(location.search);
return { return {
...addSeries,
term: params.term, term: params.term,
...addSeries hasExistingSeries: existingSeriesCount > 0
}; };
} }
); );

View File

@ -66,9 +66,11 @@ class AddNewSeriesModalContent extends Component {
languageProfileId, languageProfileId,
seriesType, seriesType,
seasonFolder, seasonFolder,
folder,
tags, tags,
showLanguageProfile, showLanguageProfile,
isSmallScreen, isSmallScreen,
isWindows,
onModalClose, onModalClose,
onInputChange, onInputChange,
...otherProps ...otherProps
@ -115,6 +117,15 @@ class AddNewSeriesModalContent extends Component {
<FormInputGroup <FormInputGroup
type={inputTypes.ROOT_FOLDER_SELECT} type={inputTypes.ROOT_FOLDER_SELECT}
name="rootFolderPath" name="rootFolderPath"
valueOptions={{
seriesFolder: folder,
isWindows
}}
selectedValueOptions={{
seriesFolder: folder,
isWindows
}}
helpText={`'${folder}' subfolder will be created automatically`}
onChange={onInputChange} onChange={onInputChange}
{...rootFolderPath} {...rootFolderPath}
/> />
@ -260,9 +271,11 @@ AddNewSeriesModalContent.propTypes = {
languageProfileId: PropTypes.object, languageProfileId: PropTypes.object,
seriesType: PropTypes.object.isRequired, seriesType: PropTypes.object.isRequired,
seasonFolder: PropTypes.object.isRequired, seasonFolder: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
tags: PropTypes.object.isRequired, tags: PropTypes.object.isRequired,
showLanguageProfile: PropTypes.bool.isRequired, showLanguageProfile: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired,
isWindows: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired, onInputChange: PropTypes.func.isRequired,
onAddSeriesPress: PropTypes.func.isRequired onAddSeriesPress: PropTypes.func.isRequired

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions'; import { setAddSeriesDefault, addSeries } from 'Store/Actions/addSeriesActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import selectSettings from 'Store/Selectors/selectSettings'; import selectSettings from 'Store/Selectors/selectSettings';
import AddNewSeriesModalContent from './AddNewSeriesModalContent'; import AddNewSeriesModalContent from './AddNewSeriesModalContent';
@ -12,7 +13,8 @@ function createMapStateToProps() {
(state) => state.addSeries, (state) => state.addSeries,
(state) => state.settings.languageProfiles, (state) => state.settings.languageProfiles,
createDimensionsSelector(), createDimensionsSelector(),
(addSeriesState, languageProfiles, dimensions) => { createSystemStatusSelector(),
(addSeriesState, languageProfiles, dimensions, systemStatus) => {
const { const {
isAdding, isAdding,
addError, addError,
@ -32,6 +34,7 @@ function createMapStateToProps() {
isSmallScreen: dimensions.isSmallScreen, isSmallScreen: dimensions.isSmallScreen,
validationErrors, validationErrors,
validationWarnings, validationWarnings,
isWindows: systemStatus.isWindows,
...settings ...settings
}; };
} }

View File

@ -53,6 +53,7 @@ class AddNewSeriesSearchResult extends Component {
overview, overview,
statistics, statistics,
ratings, ratings,
folder,
images, images,
isExistingSeries, isExistingSeries,
isSmallScreen isSmallScreen
@ -160,6 +161,7 @@ class AddNewSeriesSearchResult extends Component {
title={title} title={title}
year={year} year={year}
overview={overview} overview={overview}
folder={folder}
images={images} images={images}
onModalClose={this.onAddSeriesModalClose} onModalClose={this.onAddSeriesModalClose}
/> />
@ -178,6 +180,7 @@ AddNewSeriesSearchResult.propTypes = {
overview: PropTypes.string, overview: PropTypes.string,
statistics: PropTypes.object.isRequired, statistics: PropTypes.object.isRequired,
ratings: PropTypes.object.isRequired, ratings: PropTypes.object.isRequired,
folder: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired,
isExistingSeries: PropTypes.bool.isRequired, isExistingSeries: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired isSmallScreen: PropTypes.bool.isRequired

View File

@ -262,6 +262,7 @@ class EnhancedSelectInput extends Component {
isDisabled, isDisabled,
hasError, hasError,
hasWarning, hasWarning,
valueOptions,
selectedValueOptions, selectedValueOptions,
selectedValueComponent: SelectedValueComponent, selectedValueComponent: SelectedValueComponent,
optionComponent: OptionComponent optionComponent: OptionComponent
@ -363,6 +364,7 @@ class EnhancedSelectInput extends Component {
key={v.key} key={v.key}
id={v.key} id={v.key}
isSelected={index === selectedIndex} isSelected={index === selectedIndex}
{...valueOptions}
{...v} {...v}
isMobile={false} isMobile={false}
onSelect={this.onSelect} onSelect={this.onSelect}
@ -404,6 +406,7 @@ class EnhancedSelectInput extends Component {
key={v.key} key={v.key}
id={v.key} id={v.key}
isSelected={index === selectedIndex} isSelected={index === selectedIndex}
{...valueOptions}
{...v} {...v}
isMobile={true} isMobile={true}
onSelect={this.onSelect} onSelect={this.onSelect}
@ -431,6 +434,7 @@ EnhancedSelectInput.propTypes = {
isDisabled: PropTypes.bool, isDisabled: PropTypes.bool,
hasError: PropTypes.bool, hasError: PropTypes.bool,
hasWarning: PropTypes.bool, hasWarning: PropTypes.bool,
valueOptions: PropTypes.object.isRequired,
selectedValueOptions: PropTypes.object.isRequired, selectedValueOptions: PropTypes.object.isRequired,
selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired, selectedValueComponent: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).isRequired,
optionComponent: PropTypes.elementType, optionComponent: PropTypes.elementType,
@ -441,6 +445,7 @@ EnhancedSelectInput.defaultProps = {
className: styles.enhancedSelect, className: styles.enhancedSelect,
disabledClassName: styles.isDisabled, disabledClassName: styles.isDisabled,
isDisabled: false, isDisabled: false,
valueOptions: {},
selectedValueOptions: {}, selectedValueOptions: {},
selectedValueComponent: HintedSelectInputSelectedValue, selectedValueComponent: HintedSelectInputSelectedValue,
optionComponent: HintedSelectInputOption optionComponent: HintedSelectInputOption

View File

@ -1,4 +1,3 @@
import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -13,7 +12,7 @@ function createMapStateToProps() {
(state) => state.rootFolders, (state) => state.rootFolders,
(state, { includeNoChange }) => includeNoChange, (state, { includeNoChange }) => includeNoChange,
(rootFolders, includeNoChange) => { (rootFolders, includeNoChange) => {
const values = _.map(rootFolders.items, (rootFolder) => { const values = rootFolders.items.map((rootFolder) => {
return { return {
key: rootFolder.path, key: rootFolder.path,
value: rootFolder.path, value: rootFolder.path,
@ -85,7 +84,7 @@ class RootFolderSelectInputConnector extends Component {
onChange onChange
} = this.props; } = 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]; const defaultValue = values[0];
if (defaultValue.key === ADD_NEW_KEY) { if (defaultValue.key === ADD_NEW_KEY) {

View File

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

View File

@ -7,14 +7,20 @@ import styles from './RootFolderSelectInputOption.css';
function RootFolderSelectInputOption(props) { function RootFolderSelectInputOption(props) {
const { const {
id,
value, value,
freeSpace, freeSpace,
seriesFolder,
isMobile, isMobile,
isWindows,
...otherProps ...otherProps
} = props; } = props;
const slashCharacter = isWindows ? '\\' : '/';
return ( return (
<EnhancedSelectInputOption <EnhancedSelectInputOption
id={id}
isMobile={isMobile} isMobile={isMobile}
{...otherProps} {...otherProps}
> >
@ -23,7 +29,18 @@ function RootFolderSelectInputOption(props) {
isMobile && styles.isMobile isMobile && styles.isMobile
)} )}
> >
<div>{value}</div> <div className={styles.value}>
{value}
{
seriesFolder && id !== 'addNew' ?
<div className={styles.seriesFolder}>
{slashCharacter}
{seriesFolder}
</div> :
null
}
</div>
{ {
freeSpace != null && freeSpace != null &&
@ -37,9 +54,12 @@ function RootFolderSelectInputOption(props) {
} }
RootFolderSelectInputOption.propTypes = { RootFolderSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
freeSpace: PropTypes.number, freeSpace: PropTypes.number,
isMobile: PropTypes.bool.isRequired seriesFolder: PropTypes.string,
isMobile: PropTypes.bool.isRequired,
isWindows: PropTypes.bool
}; };
export default RootFolderSelectInputOption; export default RootFolderSelectInputOption;

View File

@ -7,10 +7,20 @@
overflow: hidden; overflow: hidden;
} }
.pathContainer {
@add-mixin truncate;
display: flex;
flex: 1 0 0;
}
.path { .path {
@add-mixin truncate; @add-mixin truncate;
flex: 0 1 auto;
}
flex: 1 0 0; .seriesFolder {
flex: 0 1 auto;
color: $disabledColor;
} }
.freeSpace { .freeSpace {

View File

@ -8,17 +8,32 @@ function RootFolderSelectInputSelectedValue(props) {
const { const {
value, value,
freeSpace, freeSpace,
seriesFolder,
includeFreeSpace, includeFreeSpace,
isWindows,
...otherProps ...otherProps
} = props; } = props;
const slashCharacter = isWindows ? '\\' : '/';
return ( return (
<EnhancedSelectInputSelectedValue <EnhancedSelectInputSelectedValue
className={styles.selectedValue} className={styles.selectedValue}
{...otherProps} {...otherProps}
> >
<div className={styles.path}> <div className={styles.pathContainer}>
{value} <div className={styles.path}>
{value}
</div>
{
seriesFolder ?
<div className={styles.seriesFolder}>
{slashCharacter}
{seriesFolder}
</div> :
null
}
</div> </div>
{ {
@ -34,6 +49,8 @@ function RootFolderSelectInputSelectedValue(props) {
RootFolderSelectInputSelectedValue.propTypes = { RootFolderSelectInputSelectedValue.propTypes = {
value: PropTypes.string, value: PropTypes.string,
freeSpace: PropTypes.number, freeSpace: PropTypes.number,
seriesFolder: PropTypes.string,
isWindows: PropTypes.bool,
includeFreeSpace: PropTypes.bool.isRequired includeFreeSpace: PropTypes.bool.isRequired
}; };

View File

@ -20,7 +20,7 @@ function NoSeries(props) {
return ( return (
<div> <div>
<div className={styles.message}> <div className={styles.message}>
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.
</div> </div>
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>

View File

@ -81,6 +81,15 @@ namespace NzbDrone.Common.Extensions
return Directory.GetParent(cleanPath)?.FullName; 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) public static bool IsParentPath(this string parentPath, string childPath)
{ {
if (parentPath != "/" && !parentPath.EndsWith(":\\")) if (parentPath != "/" && !parentPath.EndsWith(":\\"))

View File

@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.RootFolders; using NzbDrone.Core.RootFolders;
using Sonarr.Http.REST; using Sonarr.Http.REST;
@ -23,7 +24,7 @@ namespace Sonarr.Api.V3.RootFolders
{ {
Id = model.Id, Id = model.Id,
Path = model.Path, Path = model.Path.GetCleanPath(),
FreeSpace = model.FreeSpace, FreeSpace = model.FreeSpace,
UnmappedFolders = model.UnmappedFolders UnmappedFolders = model.UnmappedFolders
}; };
@ -37,7 +38,7 @@ namespace Sonarr.Api.V3.RootFolders
{ {
Id = resource.Id, Id = resource.Id,
Path = resource.Path, Path = resource.Path
//FreeSpace //FreeSpace
//UnmappedFolders //UnmappedFolders
}; };

View File

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

View File

@ -3,6 +3,7 @@ using System.Linq;
using Nancy; using Nancy;
using NzbDrone.Core.MediaCover; using NzbDrone.Core.MediaCover;
using NzbDrone.Core.MetadataSource; using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Organizer;
using NzbDrone.Core.SeriesStats; using NzbDrone.Core.SeriesStats;
using Sonarr.Http; using Sonarr.Http;
using Sonarr.Http.Extensions; using Sonarr.Http.Extensions;
@ -12,33 +13,35 @@ namespace Sonarr.Api.V3.Series
public class SeriesLookupModule : SonarrRestModule<SeriesResource> public class SeriesLookupModule : SonarrRestModule<SeriesResource>
{ {
private readonly ISearchForNewSeries _searchProxy; private readonly ISearchForNewSeries _searchProxy;
private readonly IBuildFileNames _fileNameBuilder;
public SeriesLookupModule(ISearchForNewSeries searchProxy) public SeriesLookupModule(ISearchForNewSeries searchProxy, IBuildFileNames fileNameBuilder)
: base("/series/lookup") : base("/series/lookup")
{ {
_searchProxy = searchProxy; _searchProxy = searchProxy;
_fileNameBuilder = fileNameBuilder;
Get["/"] = x => Search(); Get["/"] = x => Search();
} }
private Response Search() private Response Search()
{ {
var tvDbResults = _searchProxy.SearchForNewSeries((string)Request.Query.term); var tvDbResults = _searchProxy.SearchForNewSeries((string)Request.Query.term);
return MapToResource(tvDbResults).AsResponse(); return MapToResource(tvDbResults).AsResponse();
} }
private IEnumerable<SeriesResource> MapToResource(IEnumerable<NzbDrone.Core.Tv.Series> series)
private static IEnumerable<SeriesResource> MapToResource(IEnumerable<NzbDrone.Core.Tv.Series> series)
{ {
foreach (var currentSeries in series) foreach (var currentSeries in series)
{ {
var resource = currentSeries.ToResource(); var resource = currentSeries.ToResource();
var poster = currentSeries.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster); var poster = currentSeries.Images.FirstOrDefault(c => c.CoverType == MediaCoverTypes.Poster);
if (poster != null) if (poster != null)
{ {
resource.RemotePoster = poster.Url; resource.RemotePoster = poster.Url;
} }
resource.Folder = _fileNameBuilder.GetSeriesFolder(currentSeries);
resource.Statistics = new SeriesStatistics().ToResource(resource.Seasons); resource.Statistics = new SeriesStatistics().ToResource(resource.Seasons);
yield return resource; yield return resource;

View File

@ -55,7 +55,8 @@ namespace Sonarr.Api.V3.Series
SeriesAncestorValidator seriesAncestorValidator, SeriesAncestorValidator seriesAncestorValidator,
SystemFolderValidator systemFolderValidator, SystemFolderValidator systemFolderValidator,
ProfileExistsValidator profileExistsValidator, ProfileExistsValidator profileExistsValidator,
LanguageProfileExistsValidator languageProfileExistsValidator LanguageProfileExistsValidator languageProfileExistsValidator,
SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator
) )
: base(signalRBroadcaster) : base(signalRBroadcaster)
{ {
@ -90,7 +91,10 @@ namespace Sonarr.Api.V3.Series
SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator); SharedValidator.RuleFor(s => s.LanguageProfileId).SetValidator(languageProfileExistsValidator);
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace()); 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.Title).NotEmpty();
PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator); PostValidator.RuleFor(s => s.TvdbId).GreaterThan(0).SetValidator(seriesExistsValidator);

View File

@ -56,6 +56,7 @@ namespace Sonarr.Api.V3.Series
public string ImdbId { get; set; } public string ImdbId { get; set; }
public string TitleSlug { get; set; } public string TitleSlug { get; set; }
public string RootFolderPath { get; set; } public string RootFolderPath { get; set; }
public string Folder { get; set; }
public string Certification { get; set; } public string Certification { get; set; }
public List<string> Genres { get; set; } public List<string> Genres { get; set; }
public HashSet<int> Tags { get; set; } public HashSet<int> Tags { get; set; }

View File

@ -160,6 +160,7 @@
<Compile Include="Notifications\NotificationModule.cs" /> <Compile Include="Notifications\NotificationModule.cs" />
<Compile Include="Notifications\NotificationResource.cs" /> <Compile Include="Notifications\NotificationResource.cs" />
<Compile Include="SeasonPass\SeasonPassSeriesResource.cs" /> <Compile Include="SeasonPass\SeasonPassSeriesResource.cs" />
<Compile Include="Series\SeriesFolderAsRootFolderValidator.cs" />
<Compile Include="Series\SeriesStatisticsResource.cs" /> <Compile Include="Series\SeriesStatisticsResource.cs" />
<Compile Include="Series\SeriesEditorDeleteResource.cs" /> <Compile Include="Series\SeriesEditorDeleteResource.cs" />
<Compile Include="Series\SeriesEditorResource.cs" /> <Compile Include="Series\SeriesEditorResource.cs" />