From b14e2bb6180c22c60fb795fc455708d5a470d5a0 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 7 Apr 2024 16:22:21 -0700 Subject: [PATCH] New: Auto tag artists based on tags present/absent on artists (cherry picked from commit f4c19a384bd9bb4e35c9fa0ca5d9a448c04e409e) Closes #4742 --- .../src/Components/Form/ArtistTagInput.tsx | 53 +++++++++++++++++++ .../src/Components/Form/FormInputGroup.js | 4 ++ .../Components/Form/ProviderFieldFormGroup.js | 2 + frontend/src/Helpers/Props/inputTypes.js | 2 + frontend/src/Settings/Tags/TagInUse.js | 2 +- .../Housekeepers/CleanupUnusedTagsFixture.cs | 33 ++++++++++++ .../Annotations/FieldDefinitionAttribute.cs | 3 +- .../Specifications/TagSpecification.cs | 36 +++++++++++++ .../Housekeepers/CleanupUnusedTags.cs | 36 +++++++++++-- src/NzbDrone.Core/Localization/Core/en.json | 1 + src/NzbDrone.Core/Tags/TagService.cs | 23 +++++++- 11 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 frontend/src/Components/Form/ArtistTagInput.tsx create mode 100644 src/NzbDrone.Core/AutoTagging/Specifications/TagSpecification.cs diff --git a/frontend/src/Components/Form/ArtistTagInput.tsx b/frontend/src/Components/Form/ArtistTagInput.tsx new file mode 100644 index 000000000..3edb46ec4 --- /dev/null +++ b/frontend/src/Components/Form/ArtistTagInput.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import TagInputConnector from './TagInputConnector'; + +interface ArtistTagInputProps { + name: string; + value: number | number[]; + onChange: ({ + name, + value, + }: { + name: string; + value: number | number[]; + }) => void; +} + +export default function ArtistTagInput(props: ArtistTagInputProps) { + 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/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 04e68d608..79f5aaf0e 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -4,6 +4,7 @@ import Link from 'Components/Link/Link'; import { inputTypes, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector'; +import ArtistTagInput from './ArtistTagInput'; import AutoCompleteInput from './AutoCompleteInput'; import CaptchaInputConnector from './CaptchaInputConnector'; import CheckInput from './CheckInput'; @@ -99,6 +100,9 @@ function getComponent(type) { case inputTypes.DYNAMIC_SELECT: return EnhancedSelectInputConnector; + case inputTypes.ARTIST_TAG: + return ArtistTagInput; + case inputTypes.SERIES_TYPE_SELECT: return SeriesTypeSelectInput; diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 9c2d124d7..fcdd4f2bc 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -29,6 +29,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.DYNAMIC_SELECT; } return inputTypes.SELECT; + case 'artistTag': + return inputTypes.ARTIST_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 9ec6e65df..1d08c762f 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -20,6 +20,7 @@ export const DOWNLOAD_CLIENT_SELECT = 'downloadClientSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; +export const ARTIST_TAG = 'artistTag'; export const DYNAMIC_SELECT = 'dynamicSelect'; export const TAG = 'tag'; export const TAG_SELECT = 'tagSelect'; @@ -49,6 +50,7 @@ export const all = [ DOWNLOAD_CLIENT_SELECT, ROOT_FOLDER_SELECT, SELECT, + ARTIST_TAG, DYNAMIC_SELECT, SERIES_TYPE_SELECT, TAG, 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 24a49157a..0a2399dd7 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; @@ -46,5 +49,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 4a56117c3..ed5879e5e 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -86,7 +86,8 @@ namespace NzbDrone.Core.Annotations TagSelect, RootFolder, QualityProfile, - MetadataProfile + MetadataProfile, + ArtistTag } 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..4e32671d9 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/TagSpecification.cs @@ -0,0 +1,36 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Music; +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.ArtistTag)] + public int Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Artist artist) + { + return artist.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 43e46111c..f7ee3b3da 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[] { "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } + var usedTags = new[] + { + "Artists", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", + "AutoTagging", "DownloadClients" + } .SelectMany(v => GetUsedTags(v, mapper)) + .Concat(GetAutoTaggingTagSpecificationTags(mapper)) .Distinct() .ToArray(); @@ -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 503e96643..c8d813d70 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -146,6 +146,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", "Automatic": "Automatic", "AutomaticAdd": "Automatic Add", "AutomaticSearch": "Automatic Search", diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index f97881a29..78137a125 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; @@ -121,7 +122,7 @@ namespace NzbDrone.Core.Tags var artists = _artistService.GetAllArtistsTags(); var rootFolders = _rootFolderService.All(); var indexers = _indexerService.All(); - var autotags = _autoTaggingService.All(); + var autoTags = _autoTaggingService.All(); var downloadClients = _downloadClientFactory.All(); var details = new List(); @@ -139,7 +140,7 @@ namespace NzbDrone.Core.Tags ArtistIds = artists.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), RootFolderIds = rootFolders.Where(c => c.DefaultTags.Contains(tag.Id)).Select(c => c.Id).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(), }); } @@ -190,5 +191,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(); + } } }