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:
125
src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs
Normal file
125
src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -65,7 +65,8 @@ namespace NzbDrone.Core.Annotations
|
||||
Captcha,
|
||||
OAuth,
|
||||
Device,
|
||||
TagSelect
|
||||
TagSelect,
|
||||
RootFolder
|
||||
}
|
||||
|
||||
public enum HiddenType
|
||||
|
||||
19
src/NzbDrone.Core/AutoTagging/AutoTag.cs
Normal file
19
src/NzbDrone.Core/AutoTagging/AutoTag.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
16
src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs
Normal file
16
src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs
Normal file
17
src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
128
src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs
Normal file
128
src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs
Normal file
14
src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("[]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>>());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
89
src/Radarr.Api.V3/AutoTagging/AutoTaggingController.cs
Normal file
89
src/Radarr.Api.V3/AutoTagging/AutoTaggingController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/Radarr.Api.V3/AutoTagging/AutoTaggingResource.cs
Normal file
76
src/Radarr.Api.V3/AutoTagging/AutoTaggingResource.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user