diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 874e42356..537af8b53 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -17,6 +17,7 @@ import IndexerSelectInputConnector from './IndexerSelectInputConnector'; import KeyValueListInput from './KeyValueListInput'; import LanguageSelectInputConnector from './LanguageSelectInputConnector'; import MovieMonitoredSelectInput from './MovieMonitoredSelectInput'; +import MovieTagInput from './MovieTagInput'; import NumberInput from './NumberInput'; import OAuthInputConnector from './OAuthInputConnector'; import PasswordInput from './PasswordInput'; @@ -89,6 +90,10 @@ function getComponent(type) { case inputTypes.DYNAMIC_SELECT: return EnhancedSelectInputConnector; + + case inputTypes.MOVIE_TAG: + return MovieTagInput; + case inputTypes.TAG: return TagInputConnector; diff --git a/frontend/src/Components/Form/MovieTagInput.tsx b/frontend/src/Components/Form/MovieTagInput.tsx new file mode 100644 index 000000000..258c4a27d --- /dev/null +++ b/frontend/src/Components/Form/MovieTagInput.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import TagInputConnector from './TagInputConnector'; + +interface MovieTagInputProps { + name: string; + value: number | number[]; + onChange: ({ + name, + value, + }: { + name: string; + value: number | number[]; + }) => void; +} + +export default function MovieTagInput(props: MovieTagInputProps) { + const { value, onChange, ...otherProps } = props; + const isArray = Array.isArray(value); + + const handleChange = useCallback( + ({ name, value: newValue }: { name: string; value: number[] }) => { + if (isArray) { + onChange({ name, value: newValue }); + } else { + onChange({ + name, + value: newValue.length ? newValue[newValue.length - 1] : 0, + }); + } + }, + [isArray, onChange] + ); + + let finalValue: number[] = []; + + if (isArray) { + finalValue = value; + } else if (value === 0) { + finalValue = []; + } else { + finalValue = [value]; + } + + return ( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore 2786 'TagInputConnector' isn't typed yet + + ); +} diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index c3c668379..3f3349026 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -27,6 +27,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.DYNAMIC_SELECT; } return inputTypes.SELECT; + case 'movieTag': + return inputTypes.MOVIE_TAG; case 'tag': return inputTypes.TEXT_TAG; case 'tagSelect': diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 33928075b..f93826081 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -17,6 +17,7 @@ export const INDEXER_FLAGS_SELECT = 'indexerFlagsSelect'; export const LANGUAGE_SELECT = 'languageSelect'; export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const SELECT = 'select'; +export const MOVIE_TAG = 'movieTag'; export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TEXT = 'text'; @@ -45,6 +46,7 @@ export const all = [ INDEXER_FLAGS_SELECT, LANGUAGE_SELECT, SELECT, + MOVIE_TAG, DYNAMIC_SELECT, TAG, TEXT, diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js index 9fb57d230..27228fa2e 100644 --- a/frontend/src/Settings/Tags/TagInUse.js +++ b/frontend/src/Settings/Tags/TagInUse.js @@ -12,7 +12,7 @@ export default function TagInUse(props) { return null; } - if (count > 1 && labelPlural ) { + if (count > 1 && labelPlural) { return (
{count} {labelPlural.toLowerCase()} diff --git a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs index 384ed1cd3..7157c0654 100644 --- a/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs +++ b/src/NzbDrone.Core.Test/Housekeeping/Housekeepers/CleanupUnusedTagsFixture.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; using FizzWare.NBuilder; using FluentAssertions; using NUnit.Framework; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; using NzbDrone.Core.Housekeeping.Housekeepers; using NzbDrone.Core.Profiles.Releases; using NzbDrone.Core.Tags; @@ -43,5 +46,35 @@ namespace NzbDrone.Core.Test.Housekeeping.Housekeepers Subject.Clean(); AllStoredModels.Should().HaveCount(1); } + + [Test] + public void should_not_delete_used_auto_tagging_tag_specification_tags() + { + var tags = Builder + .CreateListOfSize(2) + .All() + .With(x => x.Id = 0) + .BuildList(); + Db.InsertMany(tags); + + var autoTags = Builder.CreateListOfSize(1) + .All() + .With(x => x.Id = 0) + .With(x => x.Specifications = new List + { + new TagSpecification + { + Name = "Test", + Value = tags[0].Id + } + }) + .BuildList(); + + Mocker.GetMock().Setup(s => s.All()) + .Returns(autoTags); + + Subject.Clean(); + AllStoredModels.Should().HaveCount(1); + } } } diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index b4ec24286..5e43f8285 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -84,7 +84,8 @@ namespace NzbDrone.Core.Annotations Device, TagSelect, RootFolder, - QualityProfile + QualityProfile, + MovieTag } public enum HiddenType diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/TagSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/TagSpecification.cs new file mode 100644 index 000000000..e49ec059a --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/TagSpecification.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class TagSpecificationValidator : AbstractValidator + { + public TagSpecificationValidator() + { + RuleFor(c => c.Value).GreaterThan(0); + } + } + + public class TagSpecification : AutoTaggingSpecificationBase + { + private static readonly TagSpecificationValidator Validator = new (); + + public override int Order => 1; + public override string ImplementationName => "Tag"; + + [FieldDefinition(1, Label = "AutoTaggingSpecificationTag", Type = FieldType.MovieTag)] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Movie movie) + { + return movie.Tags.Contains(Value); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index e6d382cd7..a259dd8af 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -3,6 +3,8 @@ using System.Data; using System.Linq; using Dapper; using NzbDrone.Common.Extensions; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; using NzbDrone.Core.Datastore; namespace NzbDrone.Core.Housekeeping.Housekeepers @@ -10,17 +12,24 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers public class CleanupUnusedTags : IHousekeepingTask { private readonly IMainDatabase _database; + private readonly IAutoTaggingRepository _autoTaggingRepository; - public CleanupUnusedTags(IMainDatabase database) + public CleanupUnusedTags(IMainDatabase database, IAutoTaggingRepository autoTaggingRepository) { _database = database; + _autoTaggingRepository = autoTaggingRepository; } public void Clean() { using var mapper = _database.OpenConnection(); - var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } + var usedTags = new[] + { + "Movies", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", + "AutoTagging", "DownloadClients" + } .SelectMany(v => GetUsedTags(v, mapper)) + .Concat(GetAutoTaggingTagSpecificationTags(mapper)) .Distinct() .ToList(); @@ -45,10 +54,31 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers private int[] GetUsedTags(string table, IDbConnection mapper) { - return mapper.Query>($"SELECT DISTINCT \"Tags\" FROM \"{table}\" WHERE NOT \"Tags\" = '[]' AND NOT \"Tags\" IS NULL") + return mapper + .Query>( + $"SELECT DISTINCT \"Tags\" FROM \"{table}\" WHERE NOT \"Tags\" = '[]' AND NOT \"Tags\" IS NULL") .SelectMany(x => x) .Distinct() .ToArray(); } + + private List GetAutoTaggingTagSpecificationTags(IDbConnection mapper) + { + var tags = new List(); + var autoTags = _autoTaggingRepository.All(); + + foreach (var autoTag in autoTags) + { + foreach (var specification in autoTag.Specifications) + { + if (specification is TagSpecification tagSpec) + { + tags.Add(tagSpec.Value); + } + } + } + + return tags; + } } } diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 7ae7ba9e8..d5ba567c4 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -113,6 +113,7 @@ "AutoTaggingLoadError": "Unable to load auto tagging", "AutoTaggingNegateHelpText": "If checked, the auto tagging rule will not apply if this {implementationName} condition matches.", "AutoTaggingRequiredHelpText": "This {implementationName} condition must match for the auto tagging rule to apply. Otherwise a single {implementationName} match is sufficient.", + "AutoTaggingSpecificationTag": "Tag", "AutoUnmonitorPreviouslyDownloadedMoviesHelpText": "Movies deleted from the disk are automatically unmonitored in {appName}", "Automatic": "Automatic", "AutomaticAdd": "Automatic Add", @@ -257,8 +258,8 @@ "CustomFormats": "Custom Formats", "CustomFormatsLoadError": "Unable to load Custom Formats", "CustomFormatsSettings": "Custom Formats Settings", - "CustomFormatsSettingsTriggerInfo": "A Custom Format will be applied to a release or file when it matches at least one of each of the different condition types chosen.", "CustomFormatsSettingsSummary": "Custom Formats and Settings", + "CustomFormatsSettingsTriggerInfo": "A Custom Format will be applied to a release or file when it matches at least one of each of the different condition types chosen.", "CustomFormatsSpecificationFlag": "Flag", "CustomFormatsSpecificationRegularExpression": "Regular Expression", "CustomFormatsSpecificationRegularExpressionHelpText": "Custom Format RegEx is Case Insensitive", diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 48e519940..b8e676035 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; using NzbDrone.Core.Datastore; using NzbDrone.Core.Download; using NzbDrone.Core.ImportLists; @@ -120,7 +121,7 @@ namespace NzbDrone.Core.Tags var releaseProfiles = _releaseProfileService.All(); var movies = _movieService.AllMovieTags(); var indexers = _indexerService.All(); - var autotags = _autoTaggingService.All(); + var autoTags = _autoTaggingService.All(); var downloadClients = _downloadClientFactory.All(); var details = new List(); @@ -137,7 +138,7 @@ namespace NzbDrone.Core.Tags ReleaseProfileIds = releaseProfiles.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), MovieIds = movies.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), - AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + AutoTagIds = GetAutoTagIds(tag, autoTags), DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), }); } @@ -188,5 +189,23 @@ namespace NzbDrone.Core.Tags _repo.Delete(tagId); _eventAggregator.PublishEvent(new TagsUpdatedEvent()); } + + private List GetAutoTagIds(Tag tag, List autoTags) + { + var autoTagIds = autoTags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(); + + foreach (var autoTag in autoTags) + { + foreach (var specification in autoTag.Specifications) + { + if (specification is TagSpecification) + { + autoTagIds.Add(autoTag.Id); + } + } + } + + return autoTagIds.Distinct().ToList(); + } } }