1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-18 21:35:51 -04:00

New: Auto tagging of movies

(cherry picked from commit 335fc05dd1595b6db912ebdde51ef4667963b37d)
This commit is contained in:
Mark McDowall
2022-12-05 22:58:53 -08:00
committed by Bogdan
parent 938b69b240
commit 80099dcacb
59 changed files with 2796 additions and 122 deletions

View File

@@ -0,0 +1,125 @@
using System.Collections.Generic;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.AutoTagging
{
[TestFixture]
public class AutoTaggingServiceFixture : CoreTest<AutoTaggingService>
{
private Movie _movie;
private AutoTag _tag;
[SetUp]
public void Setup()
{
_movie = Builder<Movie>.CreateNew()
.With(m => m.MovieMetadata = new MovieMetadata
{
Genres = new List<string> { "Comedy" }
})
.Build();
_tag = new AutoTag
{
Name = "Test",
Specifications = new List<IAutoTaggingSpecification>
{
new GenreSpecification
{
Name = "Genre",
Value = new List<string>
{
"Comedy"
}
}
},
Tags = new HashSet<int> { 1 },
RemoveTagsAutomatically = false
};
}
private void GivenAutoTags(List<AutoTag> autoTags)
{
Mocker.GetMock<IAutoTaggingRepository>()
.Setup(s => s.All())
.Returns(autoTags);
}
[Test]
public void should_not_have_changes_if_there_are_no_auto_tags()
{
GivenAutoTags(new List<AutoTag>());
var result = Subject.GetTagChanges(_movie);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_have_tags_to_add_if_series_does_not_have_match_tag()
{
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_movie);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_not_have_tags_to_remove_if_series_has_matching_tag_but_remove_is_false()
{
_movie.Tags = new HashSet<int> { 1 };
_movie.MovieMetadata.Value.Genres = new List<string> { "NotComedy" };
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_movie);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().BeEmpty();
}
[Test]
public void should_have_tags_to_remove_if_series_has_matching_tag_and_remove_is_true()
{
_movie.Tags = new HashSet<int> { 1 };
_movie.MovieMetadata.Value.Genres = new List<string> { "NotComedy" };
_tag.RemoveTagsAutomatically = true;
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_movie);
result.TagsToAdd.Should().BeEmpty();
result.TagsToRemove.Should().HaveCount(1);
result.TagsToRemove.Should().Contain(1);
}
[Test]
public void should_match_if_specification_is_negated()
{
_movie.MovieMetadata.Value.Genres = new List<string> { "NotComedy" };
_tag.Specifications.First().Negate = true;
GivenAutoTags(new List<AutoTag> { _tag });
var result = Subject.GetTagChanges(_movie);
result.TagsToAdd.Should().HaveCount(1);
result.TagsToAdd.Should().Contain(1);
result.TagsToRemove.Should().BeEmpty();
}
}
}

View File

@@ -4,6 +4,7 @@ using FizzWare.NBuilder;
using Moq;
using NUnit.Framework;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MetadataSource;
using NzbDrone.Core.Movies;
@@ -56,6 +57,10 @@ namespace NzbDrone.Core.Test.MovieTests
Mocker.GetMock<IRootFolderService>()
.Setup(s => s.GetBestRootFolderPath(It.IsAny<string>(), null))
.Returns(string.Empty);
Mocker.GetMock<IAutoTaggingService>()
.Setup(s => s.GetTagChanges(_existingMovie))
.Returns(new AutoTaggingChanges());
}
private void GivenNewMovieInfo(MovieMetadata movie)

View File

@@ -65,7 +65,8 @@ namespace NzbDrone.Core.Annotations
Captcha,
OAuth,
Device,
TagSelect
TagSelect,
RootFolder
}
public enum HiddenType

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.AutoTagging
{
public class AutoTag : ModelBase
{
public AutoTag()
{
Tags = new HashSet<int>();
}
public string Name { get; set; }
public List<IAutoTaggingSpecification> Specifications { get; set; }
public bool RemoveTagsAutomatically { get; set; }
public HashSet<int> Tags { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace NzbDrone.Core.AutoTagging
{
public class AutoTaggingChanges
{
public HashSet<int> TagsToAdd { get; set; }
public HashSet<int> TagsToRemove { get; set; }
public AutoTaggingChanges()
{
TagsToAdd = new HashSet<int>();
TagsToRemove = new HashSet<int>();
}
}
}

View File

@@ -0,0 +1,17 @@
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Messaging.Events;
namespace NzbDrone.Core.AutoTagging
{
public interface IAutoTaggingRepository : IBasicRepository<AutoTag>
{
}
public class AutoTaggingRepository : BasicRepository<AutoTag>, IAutoTaggingRepository
{
public AutoTaggingRepository(IMainDatabase database, IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
}
}
}

View File

@@ -0,0 +1,128 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Cache;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Movies;
using NzbDrone.Core.RootFolders;
namespace NzbDrone.Core.AutoTagging
{
public interface IAutoTaggingService
{
void Update(AutoTag autoTag);
AutoTag Insert(AutoTag autoTag);
List<AutoTag> All();
AutoTag GetById(int id);
void Delete(int id);
List<AutoTag> AllForTag(int tagId);
AutoTaggingChanges GetTagChanges(Movie movie);
}
public class AutoTaggingService : IAutoTaggingService
{
private readonly IAutoTaggingRepository _repository;
private readonly RootFolderService _rootFolderService;
private readonly ICached<Dictionary<int, AutoTag>> _cache;
public AutoTaggingService(IAutoTaggingRepository repository,
RootFolderService rootFolderService,
ICacheManager cacheManager)
{
_repository = repository;
_rootFolderService = rootFolderService;
_cache = cacheManager.GetCache<Dictionary<int, AutoTag>>(typeof(AutoTag), "autoTags");
}
private Dictionary<int, AutoTag> AllDictionary()
{
return _cache.Get("all", () => _repository.All().ToDictionary(m => m.Id));
}
public List<AutoTag> All()
{
return AllDictionary().Values.ToList();
}
public AutoTag GetById(int id)
{
return AllDictionary()[id];
}
public void Update(AutoTag autoTag)
{
_repository.Update(autoTag);
_cache.Clear();
}
public AutoTag Insert(AutoTag autoTag)
{
var result = _repository.Insert(autoTag);
_cache.Clear();
return result;
}
public void Delete(int id)
{
_repository.Delete(id);
_cache.Clear();
}
public List<AutoTag> AllForTag(int tagId)
{
return All().Where(p => p.Tags.Contains(tagId))
.ToList();
}
public AutoTaggingChanges GetTagChanges(Movie movie)
{
var autoTags = All();
var changes = new AutoTaggingChanges();
if (autoTags.Empty())
{
return changes;
}
// Set the root folder path on the series
movie.RootFolderPath = _rootFolderService.GetBestRootFolderPath(movie.Path);
foreach (var autoTag in autoTags)
{
var specificationMatches = autoTag.Specifications
.GroupBy(t => t.GetType())
.Select(g => new SpecificationMatchesGroup
{
Matches = g.ToDictionary(t => t, t => t.IsSatisfiedBy(movie))
})
.ToList();
var allMatch = specificationMatches.All(x => x.DidMatch);
var tags = autoTag.Tags;
if (allMatch)
{
foreach (var tag in tags)
{
if (!movie.Tags.Contains(tag))
{
changes.TagsToAdd.Add(tag);
}
}
continue;
}
if (autoTag.RemoveTagsAutomatically)
{
foreach (var tag in tags)
{
changes.TagsToRemove.Add(tag);
}
}
}
return changes;
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.AutoTagging.Specifications;
namespace NzbDrone.Core.AutoTagging
{
public class SpecificationMatchesGroup
{
public Dictionary<IAutoTaggingSpecification, bool> Matches { get; set; }
public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) ||
Matches.All(m => m.Value == false));
}
}

View File

@@ -0,0 +1,36 @@
using NzbDrone.Core.Movies;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public abstract class AutoTaggingSpecificationBase : IAutoTaggingSpecification
{
public abstract int Order { get; }
public abstract string ImplementationName { get; }
public string Name { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public IAutoTaggingSpecification Clone()
{
return (IAutoTaggingSpecification)MemberwiseClone();
}
public abstract NzbDroneValidationResult Validate();
public bool IsSatisfiedBy(Movie movie)
{
var match = IsSatisfiedByWithoutNegate(movie);
if (Negate)
{
match = !match;
}
return match;
}
protected abstract bool IsSatisfiedByWithoutNegate(Movie movie);
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class GenreSpecificationValidator : AbstractValidator<GenreSpecification>
{
public GenreSpecificationValidator()
{
RuleFor(c => c.Value).NotEmpty();
}
}
public class GenreSpecification : AutoTaggingSpecificationBase
{
private static readonly GenreSpecificationValidator Validator = new ();
public override int Order => 1;
public override string ImplementationName => "Genre";
[FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)]
public IEnumerable<string> Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)
{
return movie.MovieMetadata.Value.Genres.Any(genre => Value.Contains(genre));
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,18 @@
using NzbDrone.Core.Movies;
using NzbDrone.Core.Validation;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public interface IAutoTaggingSpecification
{
int Order { get; }
string ImplementationName { get; }
string Name { get; set; }
bool Negate { get; set; }
bool Required { get; set; }
NzbDroneValidationResult Validate();
IAutoTaggingSpecification Clone();
bool IsSatisfiedBy(Movie movie);
}
}

View File

@@ -0,0 +1,38 @@
using FluentValidation;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Annotations;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
namespace NzbDrone.Core.AutoTagging.Specifications
{
public class RootFolderSpecificationValidator : AbstractValidator<RootFolderSpecification>
{
public RootFolderSpecificationValidator()
{
RuleFor(c => c.Value).IsValidPath();
}
}
public class RootFolderSpecification : AutoTaggingSpecificationBase
{
private static readonly RootFolderSpecificationValidator Validator = new ();
public override int Order => 1;
public override string ImplementationName => "Root Folder";
[FieldDefinition(1, Label = "Root Folder", Type = FieldType.RootFolder)]
public string Value { get; set; }
protected override bool IsSatisfiedByWithoutNegate(Movie movie)
{
return movie.RootFolderPath.PathEquals(Value);
}
public override NzbDroneValidationResult Validate()
{
return new NzbDroneValidationResult(Validator.Validate(this));
}
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using NzbDrone.Core.AutoTagging.Specifications;
namespace NzbDrone.Core.Datastore.Converters
{
public class AutoTaggingSpecificationConverter : JsonConverter<List<IAutoTaggingSpecification>>
{
public override void Write(Utf8JsonWriter writer, List<IAutoTaggingSpecification> value, JsonSerializerOptions options)
{
var wrapped = value.Select(x => new SpecificationWrapper
{
Type = x.GetType().Name,
Body = x
});
JsonSerializer.Serialize(writer, wrapped, options);
}
public override List<IAutoTaggingSpecification> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
ValidateToken(reader, JsonTokenType.StartArray);
var results = new List<IAutoTaggingSpecification>();
reader.Read(); // Advance to the first object after the StartArray token. This should be either a StartObject token, or the EndArray token. Anything else is invalid.
while (reader.TokenType == JsonTokenType.StartObject)
{
reader.Read(); // Move to type property name
ValidateToken(reader, JsonTokenType.PropertyName);
reader.Read(); // Move to type property value
ValidateToken(reader, JsonTokenType.String);
var typename = reader.GetString();
reader.Read(); // Move to body property name
ValidateToken(reader, JsonTokenType.PropertyName);
reader.Read(); // Move to start of object (stored in this property)
ValidateToken(reader, JsonTokenType.StartObject); // Start of specification
var type = Type.GetType($"NzbDrone.Core.AutoTagging.Specifications.{typename}, Radarr.Core", true);
var item = (IAutoTaggingSpecification)JsonSerializer.Deserialize(ref reader, type, options);
results.Add(item);
reader.Read(); // Move past end of body object
reader.Read(); // Move past end of 'wrapper' object
}
ValidateToken(reader, JsonTokenType.EndArray);
return results;
}
// Helper function for validating where you are in the JSON
private void ValidateToken(Utf8JsonReader reader, JsonTokenType tokenType)
{
if (reader.TokenType != tokenType)
{
throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token");
}
}
private class SpecificationWrapper
{
public string Type { get; set; }
public object Body { get; set; }
}
}
}

View File

@@ -0,0 +1,18 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(227)]
public class add_auto_tagging : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Create.TableForModel("AutoTagging")
.WithColumn("Name").AsString().Unique()
.WithColumn("Specifications").AsString().WithDefaultValue("[]")
.WithColumn("RemoveTagsAutomatically").AsBoolean().WithDefaultValue(false)
.WithColumn("Tags").AsString().WithDefaultValue("[]");
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using Dapper;
using NzbDrone.Common.Reflection;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Blocklisting;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFilters;
@@ -174,6 +175,8 @@ namespace NzbDrone.Core.Datastore
.Ignore(s => s.Translations);
Mapper.Entity<MovieCollection>("Collections").RegisterModel();
Mapper.Entity<AutoTagging.AutoTag>("AutoTagging").RegisterModel();
}
private static void RegisterMappers()
@@ -188,6 +191,7 @@ namespace NzbDrone.Core.Datastore
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileQualityItem>>(new QualityIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileFormatItem>>(new CustomFormatIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ICustomFormatSpecification>>(new CustomFormatSpecificationListConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<IAutoTaggingSpecification>>(new AutoTaggingSpecificationConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<QualityModel>(new QualityIntConverter()));
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<Dictionary<string, string>>());
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<IDictionary<string, string>>());

View File

@@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
public void Clean()
{
using var mapper = _database.OpenConnection();
var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "DownloadClients" }
var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" }
.SelectMany(v => GetUsedTags(v, mapper))
.Distinct()
.ToList();

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using NLog;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Exceptions;
using NzbDrone.Core.MediaFiles;
@@ -33,7 +34,7 @@ namespace NzbDrone.Core.Movies
private readonly IDiskScanService _diskScanService;
private readonly ICheckIfMovieShouldBeRefreshed _checkIfMovieShouldBeRefreshed;
private readonly IConfigService _configService;
private readonly IAutoTaggingService _autoTaggingService;
private readonly Logger _logger;
public RefreshMovieService(IProvideMovieInfo movieInfo,
@@ -48,6 +49,7 @@ namespace NzbDrone.Core.Movies
IDiskScanService diskScanService,
ICheckIfMovieShouldBeRefreshed checkIfMovieShouldBeRefreshed,
IConfigService configService,
IAutoTaggingService autoTaggingService,
Logger logger)
{
_movieInfo = movieInfo;
@@ -62,6 +64,7 @@ namespace NzbDrone.Core.Movies
_diskScanService = diskScanService;
_checkIfMovieShouldBeRefreshed = checkIfMovieShouldBeRefreshed;
_configService = configService;
_autoTaggingService = autoTaggingService;
_logger = logger;
}
@@ -202,6 +205,39 @@ namespace NzbDrone.Core.Movies
}
}
private void UpdateTags(Movie movie)
{
_logger.Trace("Updating tags for {0}", movie);
var tagsAdded = new HashSet<int>();
var tagsRemoved = new HashSet<int>();
var changes = _autoTaggingService.GetTagChanges(movie);
foreach (var tag in changes.TagsToRemove)
{
if (movie.Tags.Contains(tag))
{
movie.Tags.Remove(tag);
tagsRemoved.Add(tag);
}
}
foreach (var tag in changes.TagsToAdd)
{
if (!movie.Tags.Contains(tag))
{
movie.Tags.Add(tag);
tagsAdded.Add(tag);
}
}
if (tagsAdded.Any() || tagsRemoved.Any())
{
_movieService.UpdateMovie(movie);
_logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", movie.Title, tagsAdded.Count, tagsRemoved.Count);
}
}
public void Execute(RefreshMovieCommand message)
{
var trigger = message.Trigger;
@@ -217,6 +253,7 @@ namespace NzbDrone.Core.Movies
try
{
movie = RefreshMovieInfo(movieId);
UpdateTags(movie);
RescanMovie(movie, isNew, trigger);
}
catch (MovieNotFoundException)
@@ -226,6 +263,7 @@ namespace NzbDrone.Core.Movies
catch (Exception e)
{
_logger.Error(e, "Couldn't refresh info for {0}", movie);
UpdateTags(movie);
RescanMovie(movie, isNew, trigger);
throw;
}
@@ -262,11 +300,13 @@ namespace NzbDrone.Core.Movies
_logger.Error(e, "Couldn't refresh info for {0}", movieLocal);
}
UpdateTags(movie);
RescanMovie(movieLocal, false, trigger);
}
else
{
_logger.Debug("Skipping refresh of movie: {0}", movieLocal.Title);
UpdateTags(movie);
RescanMovie(movieLocal, false, trigger);
}
}

View File

@@ -10,9 +10,10 @@ namespace NzbDrone.Core.Tags
public List<int> MovieIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> AutoTagIds { get; set; }
public List<int> DownloadClientIds { get; set; }
public bool InUse => MovieIds.Any() ||
@@ -21,6 +22,7 @@ namespace NzbDrone.Core.Tags
DelayProfileIds.Any() ||
ImportListIds.Any() ||
IndexerIds.Any() ||
AutoTagIds.Any() ||
DownloadClientIds.Any();
}
}

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Download;
using NzbDrone.Core.ImportLists;
@@ -35,6 +36,7 @@ namespace NzbDrone.Core.Tags
private readonly IRestrictionService _restrictionService;
private readonly IMovieService _movieService;
private readonly IIndexerFactory _indexerService;
private readonly IAutoTaggingService _autoTaggingService;
private readonly IDownloadClientFactory _downloadClientFactory;
public TagService(ITagRepository repo,
@@ -45,6 +47,7 @@ namespace NzbDrone.Core.Tags
IRestrictionService restrictionService,
IMovieService movieService,
IIndexerFactory indexerService,
IAutoTaggingService autoTaggingService,
IDownloadClientFactory downloadClientFactory)
{
_repo = repo;
@@ -55,6 +58,7 @@ namespace NzbDrone.Core.Tags
_restrictionService = restrictionService;
_movieService = movieService;
_indexerService = indexerService;
_autoTaggingService = autoTaggingService;
_downloadClientFactory = downloadClientFactory;
}
@@ -89,6 +93,7 @@ namespace NzbDrone.Core.Tags
var restrictions = _restrictionService.AllForTag(tagId);
var movies = _movieService.AllMovieTags().Where(x => x.Value.Contains(tagId)).Select(x => x.Key).ToList();
var indexers = _indexerService.AllForTag(tagId);
var autoTags = _autoTaggingService.AllForTag(tagId);
var downloadClients = _downloadClientFactory.AllForTag(tagId);
return new TagDetails
@@ -101,6 +106,7 @@ namespace NzbDrone.Core.Tags
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
MovieIds = movies,
IndexerIds = indexers.Select(c => c.Id).ToList(),
AutoTagIds = autoTags.Select(c => c.Id).ToList(),
DownloadClientIds = downloadClients.Select(c => c.Id).ToList()
};
}
@@ -114,6 +120,7 @@ namespace NzbDrone.Core.Tags
var restrictions = _restrictionService.All();
var movies = _movieService.AllMovieTags();
var indexers = _indexerService.All();
var autotags = _autoTaggingService.All();
var downloadClients = _downloadClientFactory.All();
var details = new List<TagDetails>();
@@ -130,6 +137,7 @@ namespace NzbDrone.Core.Tags
RestrictionIds = restrictions.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(),
DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
});
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using Radarr.Http;
using Radarr.Http.REST;
using Radarr.Http.REST.Attributes;
namespace Radarr.Api.V3.AutoTagging
{
[V3ApiController]
public class AutoTaggingController : RestController<AutoTaggingResource>
{
private readonly IAutoTaggingService _autoTaggingService;
private readonly List<IAutoTaggingSpecification> _specifications;
public AutoTaggingController(IAutoTaggingService autoTaggingService,
List<IAutoTaggingSpecification> specifications)
{
_autoTaggingService = autoTaggingService;
_specifications = specifications;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name)
.Must((v, c) => !_autoTaggingService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
SharedValidator.RuleFor(c => c.Tags).NotEmpty();
SharedValidator.RuleFor(c => c.Specifications).NotEmpty();
SharedValidator.RuleFor(c => c).Custom((autoTag, context) =>
{
if (!autoTag.Specifications.Any())
{
context.AddFailure("Must contain at least one Condition");
}
if (autoTag.Specifications.Any(s => s.Name.IsNullOrWhiteSpace()))
{
context.AddFailure("Condition name(s) cannot be empty or consist of only spaces");
}
});
}
protected override AutoTaggingResource GetResourceById(int id)
{
return _autoTaggingService.GetById(id).ToResource();
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<AutoTaggingResource> Create(AutoTaggingResource autoTagResource)
{
var model = autoTagResource.ToModel(_specifications);
return Created(_autoTaggingService.Insert(model).Id);
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<AutoTaggingResource> Update(AutoTaggingResource resource)
{
var model = resource.ToModel(_specifications);
_autoTaggingService.Update(model);
return Accepted(model.Id);
}
[HttpGet]
[Produces("application/json")]
public List<AutoTaggingResource> GetAll()
{
return _autoTaggingService.All().ToResource();
}
[RestDeleteById]
public void DeleteFormat(int id)
{
_autoTaggingService.Delete(id);
}
[HttpGet("schema")]
public object GetTemplates()
{
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
return schema;
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using Radarr.Http.ClientSchema;
using Radarr.Http.REST;
namespace Radarr.Api.V3.AutoTagging
{
public class AutoTaggingResource : RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public override int Id { get; set; }
public string Name { get; set; }
public bool RemoveTagsAutomatically { get; set; }
public HashSet<int> Tags { get; set; }
public List<AutoTaggingSpecificationSchema> Specifications { get; set; }
}
public static class AutoTaggingResourceMapper
{
public static AutoTaggingResource ToResource(this AutoTag model)
{
return new AutoTaggingResource
{
Id = model.Id,
Name = model.Name,
RemoveTagsAutomatically = model.RemoveTagsAutomatically,
Tags = model.Tags,
Specifications = model.Specifications.Select(x => x.ToSchema()).ToList()
};
}
public static List<AutoTaggingResource> ToResource(this IEnumerable<AutoTag> models)
{
return models.Select(m => m.ToResource()).ToList();
}
public static AutoTag ToModel(this AutoTaggingResource resource, List<IAutoTaggingSpecification> specifications)
{
return new AutoTag
{
Id = resource.Id,
Name = resource.Name,
RemoveTagsAutomatically = resource.RemoveTagsAutomatically,
Tags = resource.Tags,
Specifications = resource.Specifications.Select(x => MapSpecification(x, specifications)).ToList()
};
}
private static IAutoTaggingSpecification MapSpecification(AutoTaggingSpecificationSchema resource, List<IAutoTaggingSpecification> specifications)
{
var matchingSpec =
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
if (matchingSpec is null)
{
throw new ArgumentException(
$"{resource.Implementation} is not a valid specification implementation");
}
var type = matchingSpec.GetType();
// Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple
// of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that
// relies on additional privacy.
// TODO: Check ReadFromSchema for third argument
var spec = (IAutoTaggingSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type);
spec.Name = resource.Name;
spec.Negate = resource.Negate;
return spec;
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using NzbDrone.Core.AutoTagging.Specifications;
using Radarr.Http.ClientSchema;
using Radarr.Http.REST;
namespace Radarr.Api.V3.AutoTagging
{
public class AutoTaggingSpecificationSchema : RestResource
{
public string Name { get; set; }
public string Implementation { get; set; }
public string ImplementationName { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public List<Field> Fields { get; set; }
}
public static class AutoTaggingSpecificationSchemaMapper
{
public static AutoTaggingSpecificationSchema ToSchema(this IAutoTaggingSpecification model)
{
return new AutoTaggingSpecificationSchema
{
Name = model.Name,
Implementation = model.GetType().Name,
ImplementationName = model.ImplementationName,
Negate = model.Negate,
Required = model.Required,
Fields = SchemaBuilder.ToSchema(model)
};
}
}
}

View File

@@ -9,12 +9,13 @@ namespace Radarr.Api.V3.Tags
{
public string Label { get; set; }
public List<int> DelayProfileIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> NotificationIds { get; set; }
public List<int> RestrictionIds { get; set; }
public List<int> ImportListIds { get; set; }
public List<int> MovieIds { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> DownloadClientIds { get; set; }
public List<int> AutoTagIds { get; set; }
public List<int> MovieIds { get; set; }
}
public static class TagDetailsResourceMapper
@@ -31,12 +32,13 @@ namespace Radarr.Api.V3.Tags
Id = model.Id,
Label = model.Label,
DelayProfileIds = model.DelayProfileIds,
ImportListIds = model.ImportListIds,
NotificationIds = model.NotificationIds,
RestrictionIds = model.RestrictionIds,
ImportListIds = model.ImportListIds,
MovieIds = model.MovieIds,
IndexerIds = model.IndexerIds,
DownloadClientIds = model.DownloadClientIds,
AutoTagIds = model.AutoTagIds,
MovieIds = model.MovieIds,
};
}