Previously, Trash Updater would crawl & parse the Trash Guide's markdown files to obtain information about release profiles. This is complex and error prone. Thanks to work done by Nitsua, we now have JSON files available that describe release profiles in a more concise way. These files are located at `docs/json/sonarr` in the [Trash Guide repo][1]. All of the markdown parsing code has been removed from Trash Updater. Now, it shares the same git clone of the Trash Guide repository originally used for Radarr custom formats to access those release profile JSON files. BREAKING CHANGE: The old `type:` property for release profiles is removed in favor of `trash_id:`, which identifies a specific JSON file to pull data from. Users are required to update their `trash.yml` and other configuration files to use the new schema. Until changes are made, users will see errors when they run `trash sonarr` commands. [1]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarrpull/56/head
parent
e86b83c9ab
commit
434158f7a6
@ -1,39 +0,0 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Validators;
|
||||
|
||||
namespace Common.Extensions;
|
||||
|
||||
public static class FluentValidationExtensions
|
||||
{
|
||||
// From: https://github.com/FluentValidation/FluentValidation/issues/1648
|
||||
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
|
||||
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
|
||||
{
|
||||
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
|
||||
{
|
||||
RuleSets = ruleSets
|
||||
};
|
||||
|
||||
return ruleBuilder.SetAsyncValidator(adapter);
|
||||
}
|
||||
|
||||
private sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
|
||||
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
|
||||
{
|
||||
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
|
||||
: base(validator, validatorType)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
return base.IsValidAsync(context, value!, cancellation);
|
||||
}
|
||||
|
||||
public override bool IsValid(ValidationContext<T> context, TProperty? value)
|
||||
{
|
||||
return base.IsValid(context, value!);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Common;
|
||||
namespace Common.Extensions;
|
||||
|
||||
public static class JsonNetExtensions
|
||||
{
|
@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
|
||||
namespace Common.FluentValidation;
|
||||
|
||||
public static class FluentValidationExtensions
|
||||
{
|
||||
// From: https://github.com/FluentValidation/FluentValidation/issues/1648
|
||||
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
|
||||
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
|
||||
{
|
||||
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
|
||||
{
|
||||
RuleSets = ruleSets
|
||||
};
|
||||
|
||||
return ruleBuilder.SetAsyncValidator(adapter);
|
||||
}
|
||||
|
||||
public static IEnumerable<TSource> IsValid<TSource, TValidator>(
|
||||
this IEnumerable<TSource> source, TValidator validator,
|
||||
Action<List<ValidationFailure>, TSource>? handleInvalid = null)
|
||||
where TValidator : IValidator<TSource>, new()
|
||||
{
|
||||
foreach (var s in source)
|
||||
{
|
||||
var result = validator.Validate(s);
|
||||
if (result.IsValid)
|
||||
{
|
||||
yield return s;
|
||||
}
|
||||
else
|
||||
{
|
||||
handleInvalid?.Invoke(result.Errors, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Validators;
|
||||
|
||||
namespace Common.FluentValidation;
|
||||
|
||||
internal sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
|
||||
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
|
||||
{
|
||||
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
|
||||
: base(validator, validatorType)
|
||||
{
|
||||
}
|
||||
|
||||
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
|
||||
CancellationToken cancellation)
|
||||
{
|
||||
return base.IsValidAsync(context, value!, cancellation);
|
||||
}
|
||||
|
||||
public override bool IsValid(ValidationContext<T> context, TProperty? value)
|
||||
{
|
||||
return base.IsValid(context, value!);
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
using YamlDotNet.Core.Events;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace Common.YamlDotNet;
|
||||
|
||||
// from: https://github.com/aaubry/YamlDotNet/issues/236#issuecomment-632054372
|
||||
public sealed class ReadOnlyCollectionNodeTypeResolver : INodeTypeResolver
|
||||
{
|
||||
public bool Resolve(NodeEvent? nodeEvent, ref Type currentType)
|
||||
{
|
||||
if (!currentType.IsInterface || !currentType.IsGenericType ||
|
||||
!CustomGenericInterfaceImplementations.TryGetValue(currentType.GetGenericTypeDefinition(),
|
||||
out var concreteType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
currentType = concreteType.MakeGenericType(currentType.GetGenericArguments());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<Type, Type> CustomGenericInterfaceImplementations =
|
||||
new Dictionary<Type, Type>
|
||||
{
|
||||
{typeof(IReadOnlyCollection<>), typeof(List<>)},
|
||||
{typeof(IReadOnlyList<>), typeof(List<>)},
|
||||
{typeof(IReadOnlyDictionary<,>), typeof(Dictionary<,>)}
|
||||
};
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using AutoFixture.NUnit3;
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.AutoFixture;
|
||||
using TrashLib.Radarr.Config;
|
||||
using TrashLib.Radarr.CustomFormat.Guide;
|
||||
|
||||
namespace TrashLib.Tests.Radarr.CustomFormat.Guide;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class LocalRepoCustomFormatJsonParserTest
|
||||
{
|
||||
[Test, AutoMockData]
|
||||
public void Get_custom_format_json_works(
|
||||
[Frozen] IResourcePaths paths,
|
||||
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
|
||||
LocalRepoCustomFormatJsonParser sut)
|
||||
{
|
||||
paths.RepoPath.Returns("");
|
||||
fileSystem.AddFile("docs/json/radarr/first.json", new MockFileData("first"));
|
||||
fileSystem.AddFile("docs/json/radarr/second.json", new MockFileData("second"));
|
||||
|
||||
var results = sut.GetCustomFormatJson();
|
||||
|
||||
results.Should().BeEquivalentTo("first", "second");
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
using System.IO.Abstractions.TestingHelpers;
|
||||
using AutoFixture.NUnit3;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.AutoFixture;
|
||||
using TrashLib.Radarr.Config;
|
||||
using TrashLib.Sonarr.ReleaseProfile;
|
||||
using TrashLib.Sonarr.ReleaseProfile.Guide;
|
||||
|
||||
namespace TrashLib.Tests.Sonarr.ReleaseProfile.Guide;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class LocalRepoReleaseProfileJsonParserTest
|
||||
{
|
||||
[Test, AutoMockData]
|
||||
public void Get_custom_format_json_works(
|
||||
[Frozen] IResourcePaths paths,
|
||||
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
|
||||
LocalRepoReleaseProfileJsonParser sut)
|
||||
{
|
||||
static ReleaseProfileData MakeMockObject(string term) => new()
|
||||
{
|
||||
Name = "name",
|
||||
TrashId = "123",
|
||||
Required = new TermData[]
|
||||
{
|
||||
new() {Term = term}
|
||||
}
|
||||
};
|
||||
|
||||
static MockFileData MockFileData(dynamic obj) =>
|
||||
new MockFileData(JsonConvert.SerializeObject(obj));
|
||||
|
||||
var mockData1 = MakeMockObject("first");
|
||||
var mockData2 = MakeMockObject("second");
|
||||
|
||||
paths.RepoPath.Returns("");
|
||||
fileSystem.AddFile("docs/json/sonarr/first.json", MockFileData(mockData1));
|
||||
fileSystem.AddFile("docs/json/sonarr/second.json", MockFileData(mockData2));
|
||||
|
||||
var results = sut.GetReleaseProfileData();
|
||||
|
||||
results.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
mockData1,
|
||||
mockData2
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.AutoFixture;
|
||||
using TrashLib.Sonarr.Config;
|
||||
using TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class ReleaseProfileDataFiltererTest
|
||||
{
|
||||
[Test, AutoMockData]
|
||||
public void Include_terms_filter_works(ReleaseProfileDataFilterer sut)
|
||||
{
|
||||
var filter = new[] {"1", "2"};
|
||||
var terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"},
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
};
|
||||
|
||||
var result = sut.IncludeTerms(terms, filter);
|
||||
|
||||
result.Should().BeEquivalentTo(new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"}
|
||||
});
|
||||
}
|
||||
|
||||
[Test, AutoMockData]
|
||||
public void Include_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
|
||||
{
|
||||
var filter = new[] {"1", "2"};
|
||||
var terms = new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"},
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Score = 20, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "4", Term = "term4"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = sut.IncludeTerms(terms, filter);
|
||||
|
||||
result.Should().BeEquivalentTo(new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test, AutoMockData]
|
||||
public void Exclude_terms_filter_works(ReleaseProfileDataFilterer sut)
|
||||
{
|
||||
var filter = new[] {"1", "2"};
|
||||
var terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"},
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
};
|
||||
|
||||
var result = sut.ExcludeTerms(terms, filter);
|
||||
|
||||
result.Should().BeEquivalentTo(new TermData[]
|
||||
{
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
});
|
||||
}
|
||||
|
||||
[Test, AutoMockData]
|
||||
public void Exclude_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
|
||||
{
|
||||
var filter = new[] {"1", "2"};
|
||||
var terms = new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10,
|
||||
Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"},
|
||||
new() {TrashId = "2", Term = "term2"},
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Score = 20,
|
||||
Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "4", Term = "term4"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = sut.ExcludeTerms(terms, filter);
|
||||
|
||||
result.Should().BeEquivalentTo(new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10,
|
||||
Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Score = 20,
|
||||
Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "4", Term = "term4"}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
[Test, AutoMockData]
|
||||
public void Filter_profile_data_with_invalid_terms(ReleaseProfileDataFilterer sut)
|
||||
{
|
||||
var profileData = new ReleaseProfileData
|
||||
{
|
||||
Preferred = new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "1", Term = "term1"}, // excluded by filter
|
||||
new() {TrashId = "2", Term = ""}, // excluded because it's invalid
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Score = 20, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "4", Term = "term4"}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var filter = new SonarrProfileFilterConfig
|
||||
{
|
||||
Exclude = new[] {"1"}
|
||||
};
|
||||
|
||||
var result = sut.FilterProfile(profileData, filter);
|
||||
|
||||
result.Should().BeEquivalentTo(new ReleaseProfileData
|
||||
{
|
||||
Preferred = new PreferredTermData[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 10, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "3", Term = "term3"}
|
||||
}
|
||||
},
|
||||
new()
|
||||
{
|
||||
Score = 20, Terms = new TermData[]
|
||||
{
|
||||
new() {TrashId = "4", Term = "term4"}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
using FluentAssertions;
|
||||
using FluentValidation.TestHelper;
|
||||
using NUnit.Framework;
|
||||
using TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class ReleaseProfileDataValidatorTest
|
||||
{
|
||||
[Test]
|
||||
public void Empty_term_collections_not_allowed()
|
||||
{
|
||||
var validator = new ReleaseProfileDataValidator();
|
||||
var data = new ReleaseProfileData();
|
||||
|
||||
validator.Validate(data).IsValid.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Allow_single_preferred_term()
|
||||
{
|
||||
var validator = new ReleaseProfileDataValidator();
|
||||
var data = new ReleaseProfileData
|
||||
{
|
||||
TrashId = "trash_id",
|
||||
Name = "name",
|
||||
Required = Array.Empty<TermData>(),
|
||||
Ignored = Array.Empty<TermData>(),
|
||||
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData()}}}
|
||||
};
|
||||
|
||||
var result = validator.TestValidate(data);
|
||||
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Allow_single_required_term()
|
||||
{
|
||||
var validator = new ReleaseProfileDataValidator();
|
||||
var data = new ReleaseProfileData
|
||||
{
|
||||
TrashId = "trash_id",
|
||||
Name = "name",
|
||||
Required = new[] {new TermData {Term = "term"}},
|
||||
Ignored = Array.Empty<TermData>(),
|
||||
Preferred = Array.Empty<PreferredTermData>()
|
||||
};
|
||||
|
||||
var result = validator.TestValidate(data);
|
||||
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Allow_single_ignored_term()
|
||||
{
|
||||
var validator = new ReleaseProfileDataValidator();
|
||||
var data = new ReleaseProfileData
|
||||
{
|
||||
TrashId = "trash_id",
|
||||
Name = "name",
|
||||
Required = Array.Empty<TermData>(),
|
||||
Ignored = new[] {new TermData {Term = "term"}},
|
||||
Preferred = Array.Empty<PreferredTermData>()
|
||||
};
|
||||
|
||||
var result = validator.TestValidate(data);
|
||||
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Term_data_validate_empty()
|
||||
{
|
||||
var validator = new TermDataValidator();
|
||||
var data = new TermData();
|
||||
|
||||
var result = validator.TestValidate(data);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x.Term);
|
||||
result.ShouldNotHaveValidationErrorFor(x => x.Name);
|
||||
result.ShouldNotHaveValidationErrorFor(x => x.TrashId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Preferred_term_data_validate_empty()
|
||||
{
|
||||
var validator = new PreferredTermDataValidator();
|
||||
var data = new PreferredTermData();
|
||||
|
||||
var result = validator.TestValidate(data);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(x => x.Terms);
|
||||
}
|
||||
}
|
@ -1,421 +0,0 @@
|
||||
using Common;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Serilog.Sinks.TestCorrelator;
|
||||
using TestLibrary;
|
||||
using TrashLib.Sonarr.Config;
|
||||
using TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class ReleaseProfileParserTest
|
||||
{
|
||||
[OneTimeSetUp]
|
||||
public void Setup()
|
||||
{
|
||||
// Formatter.AddFormatter(new ProfileDataValueFormatter());
|
||||
}
|
||||
|
||||
private class Context
|
||||
{
|
||||
public Context()
|
||||
{
|
||||
var logger = new LoggerConfiguration()
|
||||
.WriteTo.TestCorrelator()
|
||||
.MinimumLevel.Debug()
|
||||
.CreateLogger();
|
||||
|
||||
Config = new SonarrConfiguration
|
||||
{
|
||||
ReleaseProfiles = new[] {new ReleaseProfileConfig()}
|
||||
};
|
||||
|
||||
GuideParser = new ReleaseProfileGuideParser(logger);
|
||||
}
|
||||
|
||||
public SonarrConfiguration Config { get; }
|
||||
public ReleaseProfileGuideParser GuideParser { get; }
|
||||
public ResourceDataReader TestData { get; } = new(typeof(ReleaseProfileParserTest), "Data");
|
||||
|
||||
public IDictionary<string, ProfileData> ParseWithDefaults(string markdown)
|
||||
{
|
||||
return GuideParser.ParseMarkdown(Config.ReleaseProfiles.First(), markdown);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_CodeBlockScopedCategories_CategoriesSwitch()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Test Release Profile
|
||||
|
||||
Add this to must not contain (ignored)
|
||||
|
||||
```
|
||||
abc
|
||||
```
|
||||
|
||||
Add this to must contain (required)
|
||||
|
||||
```
|
||||
xyz
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should().ContainKey("Test Release Profile")
|
||||
.WhoseValue.Should().BeEquivalentTo(new
|
||||
{
|
||||
Ignored = new List<string> {"abc"},
|
||||
Required = new List<string> {"xyz"}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_HeaderCategoryFollowedByCodeBlockCategories_CodeBlockChangesCurrentCategory()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Test Release Profile
|
||||
|
||||
## Must Not Contain
|
||||
|
||||
Add this one
|
||||
|
||||
```
|
||||
abc
|
||||
```
|
||||
|
||||
Add this to must contain (required)
|
||||
|
||||
```
|
||||
xyz
|
||||
```
|
||||
|
||||
One more
|
||||
|
||||
```
|
||||
123
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should().ContainKey("Test Release Profile")
|
||||
.WhoseValue.Should().BeEquivalentTo(new
|
||||
{
|
||||
Ignored = new List<string> {"abc"},
|
||||
Required = new List<string> {"xyz", "123"}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_IgnoredRequiredPreferredScores()
|
||||
{
|
||||
var context = new Context();
|
||||
var markdown = context.TestData.ReadData("test_parse_markdown_complete_doc.md");
|
||||
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
|
||||
|
||||
results.Count.Should().Be(1);
|
||||
|
||||
var profile = results.First().Value;
|
||||
|
||||
profile.Ignored.Should().BeEquivalentTo("term2", "term3");
|
||||
profile.Required.Should().BeEquivalentTo("term4");
|
||||
profile.Preferred.Should().ContainKey(100).WhoseValue.Should().BeEquivalentTo(new List<string> {"term1"});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_IncludePreferredWhenRenaming()
|
||||
{
|
||||
var context = new Context();
|
||||
var markdown = context.TestData.ReadData("include_preferred_when_renaming.md");
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should()
|
||||
.ContainKey("First Release Profile")
|
||||
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(true);
|
||||
results.Should()
|
||||
.ContainKey("Second Release Profile")
|
||||
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(false);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_IndentedIncludePreferred_ShouldBeParsed()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Release Profile 1
|
||||
|
||||
!!! Warning
|
||||
Do not check include preferred
|
||||
|
||||
must contain
|
||||
|
||||
```
|
||||
test1
|
||||
```
|
||||
|
||||
# Release Profile 2
|
||||
|
||||
!!! Warning
|
||||
Check include preferred
|
||||
|
||||
must contain
|
||||
|
||||
```
|
||||
test2
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
var expectedResults = new Dictionary<string, ProfileData>
|
||||
{
|
||||
{
|
||||
"Release Profile 1", new ProfileData
|
||||
{
|
||||
IncludePreferredWhenRenaming = false,
|
||||
Required = new List<string> {"test1"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Release Profile 2", new ProfileData
|
||||
{
|
||||
IncludePreferredWhenRenaming = true,
|
||||
Required = new List<string> {"test2"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
results.Should().BeEquivalentTo(expectedResults);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_OptionalTerms_AreCapturedProperly()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Optional Release Profile
|
||||
|
||||
```
|
||||
skipped1
|
||||
```
|
||||
|
||||
## Must Not Contain
|
||||
|
||||
```
|
||||
optional1
|
||||
```
|
||||
|
||||
## Preferred
|
||||
|
||||
score [10]
|
||||
|
||||
```
|
||||
optional2
|
||||
```
|
||||
|
||||
One more must contain:
|
||||
|
||||
```
|
||||
optional3
|
||||
```
|
||||
|
||||
# Second Release Profile
|
||||
|
||||
This must not contain:
|
||||
|
||||
```
|
||||
not-optional1
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
var expectedResults = new Dictionary<string, ProfileData>
|
||||
{
|
||||
{
|
||||
"Optional Release Profile", new ProfileData
|
||||
{
|
||||
Optional = new ProfileDataOptional
|
||||
{
|
||||
Ignored = new List<string> {"optional1"},
|
||||
Required = new List<string> {"optional3"},
|
||||
Preferred = new Dictionary<int, List<string>>
|
||||
{
|
||||
{10, new List<string> {"optional2"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Second Release Profile", new ProfileData
|
||||
{
|
||||
Ignored = new List<string> {"not-optional1"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
results.Should().BeEquivalentTo(expectedResults);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_PotentialScore_WarningLogged()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# First Release Profile
|
||||
|
||||
The below line should be a score but isn't because it's missing the word 'score'.
|
||||
|
||||
Use this number [100]
|
||||
|
||||
```
|
||||
abc
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
|
||||
const string expectedLog =
|
||||
"Found a potential score on line #5 that will be ignored because the " +
|
||||
"word 'score' is missing (This is probably a bug in the guide itself): \"[100]\"";
|
||||
|
||||
TestCorrelator.GetLogEventsFromCurrentContext()
|
||||
.Should().ContainSingle(evt => evt.RenderMessage(default) == expectedLog);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_ScoreWithoutCategory_ImplicitlyPreferred()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Test Release Profile
|
||||
|
||||
score is [100]
|
||||
|
||||
```
|
||||
abc
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should()
|
||||
.ContainKey("Test Release Profile")
|
||||
.WhoseValue.Preferred.Should()
|
||||
.BeEquivalentTo(new Dictionary<int, List<string>>
|
||||
{
|
||||
{100, new List<string> {"abc"}}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_SkippableLines_AreSkippedWithLog()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# First Release Profile
|
||||
|
||||
!!! Admonition lines are skipped
|
||||
Indented lines are skipped
|
||||
");
|
||||
// List of substrings of logs that should appear in the resulting list of logs after parsing is done.
|
||||
// We are only looking for logs relevant to the skipped lines we're testing for.
|
||||
var expectedLogs = new List<string>
|
||||
{
|
||||
"Skip Admonition",
|
||||
"Skip Indented Line"
|
||||
};
|
||||
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should().BeEmpty();
|
||||
|
||||
var ctx = TestCorrelator.GetLogEventsFromCurrentContext().ToList();
|
||||
foreach (var log in expectedLogs)
|
||||
{
|
||||
ctx.Should().Contain(evt => evt.MessageTemplate.Text.Contains(log));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_StrictNegativeScores()
|
||||
{
|
||||
var context = new Context();
|
||||
context.Config.ReleaseProfiles = new List<ReleaseProfileConfig>
|
||||
{
|
||||
new() {StrictNegativeScores = true}
|
||||
};
|
||||
|
||||
var markdown = context.TestData.ReadData("strict_negative_scores.md");
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
results.Should()
|
||||
.ContainKey("Test Release Profile").WhoseValue.Should()
|
||||
.BeEquivalentTo(new
|
||||
{
|
||||
Required = new { },
|
||||
Ignored = new List<string> {"abc"},
|
||||
Preferred = new Dictionary<int, List<string>> {{0, new List<string> {"xyz"}}}
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parse_TermsWithoutCategory_AreSkipped()
|
||||
{
|
||||
var markdown = StringUtils.TrimmedString(@"
|
||||
# Test Release Profile
|
||||
|
||||
```
|
||||
skipped1
|
||||
```
|
||||
|
||||
## Must Not Contain
|
||||
|
||||
```
|
||||
added1
|
||||
```
|
||||
|
||||
## Preferred
|
||||
|
||||
score [10]
|
||||
|
||||
```
|
||||
added2
|
||||
```
|
||||
|
||||
One more
|
||||
|
||||
```
|
||||
added3
|
||||
```
|
||||
|
||||
# Second Release Profile
|
||||
|
||||
```
|
||||
skipped2
|
||||
```
|
||||
");
|
||||
var context = new Context();
|
||||
var results = context.ParseWithDefaults(markdown);
|
||||
|
||||
var expectedResults = new Dictionary<string, ProfileData>
|
||||
{
|
||||
{
|
||||
"Test Release Profile", new ProfileData
|
||||
{
|
||||
Ignored = new List<string> {"added1"},
|
||||
Preferred = new Dictionary<int, List<string>>
|
||||
{
|
||||
{10, new List<string> {"added2", "added3"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
results.Should().BeEquivalentTo(expectedResults);
|
||||
}
|
||||
}
|
@ -1,148 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using TrashLib.Sonarr.Config;
|
||||
using TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
|
||||
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class UtilsTest
|
||||
{
|
||||
private static readonly SonarrProfileFilterConfig _filterIncludeOptional = new() {IncludeOptional = true};
|
||||
private static readonly SonarrProfileFilterConfig _filterExcludeOptional = new() {IncludeOptional = false};
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_ignored_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData {Ignored = new List<string> {"term"}};
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_required_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData {Required = new List<string> {"term"}};
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_preferred_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData
|
||||
{
|
||||
Preferred = new Dictionary<int, List<string>>
|
||||
{
|
||||
{100, new List<string> {"term"}}
|
||||
}
|
||||
};
|
||||
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_optional_ignored_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData
|
||||
{
|
||||
Optional = new ProfileDataOptional
|
||||
{
|
||||
Ignored = new List<string> {"term"}
|
||||
}
|
||||
};
|
||||
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_optional_required_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData
|
||||
{
|
||||
Optional = new ProfileDataOptional
|
||||
{
|
||||
Required = new List<string> {"required1"}
|
||||
}
|
||||
};
|
||||
|
||||
var data = new Dictionary<string, ProfileData>
|
||||
{
|
||||
{"actualData", profileData}
|
||||
};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_optional_preferred_should_not_be_filtered_out()
|
||||
{
|
||||
var profileData = new ProfileData
|
||||
{
|
||||
Optional = new ProfileDataOptional
|
||||
{
|
||||
Preferred = new Dictionary<int, List<string>>
|
||||
{
|
||||
{100, new List<string> {"term"}}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().BeEquivalentTo(data);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Empty_profiles_should_be_filtered_out()
|
||||
{
|
||||
var data = new Dictionary<string, ProfileData>
|
||||
{
|
||||
{"emptyData", new ProfileData()}
|
||||
};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
|
||||
|
||||
filteredData.Should().NotContainKey("emptyData");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Profile_with_only_optionals_should_be_filtered_out_when_config_excludes_optionals()
|
||||
{
|
||||
var profileData = new ProfileData
|
||||
{
|
||||
Optional = new ProfileDataOptional
|
||||
{
|
||||
Preferred = new Dictionary<int, List<string>>
|
||||
{
|
||||
{100, new List<string> {"term"}}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
|
||||
|
||||
var filteredData = Utils.FilterProfiles(data, _filterExcludeOptional);
|
||||
|
||||
filteredData.Should().BeEmpty();
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
using System.IO.Abstractions;
|
||||
using Common.FluentValidation;
|
||||
using MoreLinq;
|
||||
using Newtonsoft.Json;
|
||||
using TrashLib.Radarr.Config;
|
||||
|
||||
namespace TrashLib.Sonarr.ReleaseProfile.Guide;
|
||||
|
||||
public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
|
||||
{
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IResourcePaths _paths;
|
||||
|
||||
public LocalRepoReleaseProfileJsonParser(IFileSystem fileSystem, IResourcePaths paths)
|
||||
{
|
||||
_fileSystem = fileSystem;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<ReleaseProfileData> GetReleaseProfileData()
|
||||
{
|
||||
var converter = new TermDataConverter();
|
||||
var jsonDir = Path.Combine(_paths.RepoPath, "docs/json/sonarr");
|
||||
var tasks = _fileSystem.Directory.GetFiles(jsonDir, "*.json")
|
||||
.Select(async f =>
|
||||
{
|
||||
var json = await _fileSystem.File.ReadAllTextAsync(f);
|
||||
return JsonConvert.DeserializeObject<ReleaseProfileData>(json, converter);
|
||||
});
|
||||
|
||||
return Task.WhenAll(tasks).Result
|
||||
.Choose(x => x is not null ? (true, x) : default) // Make non-nullable type
|
||||
.IsValid(new ReleaseProfileDataValidator())
|
||||
.ToList();
|
||||
}
|
||||
}
|
@ -0,0 +1,104 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using Common.FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Serilog;
|
||||
using TrashLib.Sonarr.Config;
|
||||
|
||||
namespace TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
public class ReleaseProfileDataFilterer
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
|
||||
public ReleaseProfileDataFilterer(ILogger log)
|
||||
{
|
||||
_log = log;
|
||||
}
|
||||
|
||||
private void LogInvalidTerm(List<ValidationFailure> failures, string filterDescription)
|
||||
{
|
||||
_log.Debug("Validation failed on term data ({Filter}): {Failures}", filterDescription, failures);
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<TermData> ExcludeTerms(IEnumerable<TermData> terms,
|
||||
IEnumerable<string> includeFilter)
|
||||
{
|
||||
return terms
|
||||
.ExceptBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase)
|
||||
.IsValid(new TermDataValidator(), (e, x) => LogInvalidTerm(e, $"Exclude: {x}"))
|
||||
.ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<PreferredTermData> ExcludeTerms(IEnumerable<PreferredTermData> terms,
|
||||
IReadOnlyCollection<string> includeFilter)
|
||||
{
|
||||
return terms
|
||||
.Select(x => new PreferredTermData
|
||||
{
|
||||
Score = x.Score,
|
||||
Terms = ExcludeTerms(x.Terms, includeFilter)
|
||||
})
|
||||
.IsValid(new PreferredTermDataValidator(), (e, x) => LogInvalidTerm(e, $"Exclude Preferred: {x}"))
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<TermData> IncludeTerms(IEnumerable<TermData> terms,
|
||||
IEnumerable<string> includeFilter)
|
||||
{
|
||||
return terms
|
||||
.IntersectBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase)
|
||||
.IsValid(new TermDataValidator(),
|
||||
(e, x) => LogInvalidTerm(e, $"Include: {x}"))
|
||||
.ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
public ReadOnlyCollection<PreferredTermData> IncludeTerms(IEnumerable<PreferredTermData> terms,
|
||||
IReadOnlyCollection<string> includeFilter)
|
||||
{
|
||||
return terms
|
||||
.Select(x => new PreferredTermData
|
||||
{
|
||||
Score = x.Score,
|
||||
Terms = IncludeTerms(x.Terms, includeFilter)
|
||||
})
|
||||
.IsValid(new PreferredTermDataValidator(), (e, x) => LogInvalidTerm(e, $"Include Preferred {x}"))
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
|
||||
public ReleaseProfileData? FilterProfile(ReleaseProfileData selectedProfile,
|
||||
SonarrProfileFilterConfig profileFilter)
|
||||
{
|
||||
if (profileFilter.Include.Any())
|
||||
{
|
||||
_log.Debug("Using inclusion filter");
|
||||
return new ReleaseProfileData
|
||||
{
|
||||
TrashId = selectedProfile.TrashId,
|
||||
Name = selectedProfile.Name,
|
||||
IncludePreferredWhenRenaming = selectedProfile.IncludePreferredWhenRenaming,
|
||||
Required = IncludeTerms(selectedProfile.Required, profileFilter.Include),
|
||||
Ignored = IncludeTerms(selectedProfile.Ignored, profileFilter.Include),
|
||||
Preferred = IncludeTerms(selectedProfile.Preferred, profileFilter.Include)
|
||||
};
|
||||
}
|
||||
|
||||
if (profileFilter.Exclude.Any())
|
||||
{
|
||||
_log.Debug("Using exclusion filter");
|
||||
return new ReleaseProfileData
|
||||
{
|
||||
TrashId = selectedProfile.TrashId,
|
||||
Name = selectedProfile.Name,
|
||||
IncludePreferredWhenRenaming = selectedProfile.IncludePreferredWhenRenaming,
|
||||
Required = ExcludeTerms(selectedProfile.Required, profileFilter.Exclude),
|
||||
Ignored = ExcludeTerms(selectedProfile.Ignored, profileFilter.Exclude),
|
||||
Preferred = ExcludeTerms(selectedProfile.Preferred, profileFilter.Exclude)
|
||||
};
|
||||
}
|
||||
|
||||
_log.Debug("Filter property present but is empty");
|
||||
return null;
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
internal class TermDataValidator : AbstractValidator<TermData>
|
||||
{
|
||||
public TermDataValidator()
|
||||
{
|
||||
RuleFor(x => x.Term).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreferredTermDataValidator : AbstractValidator<PreferredTermData>
|
||||
{
|
||||
public PreferredTermDataValidator()
|
||||
{
|
||||
RuleFor(x => x.Terms).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
internal class ReleaseProfileDataValidator : AbstractValidator<ReleaseProfileData>
|
||||
{
|
||||
public ReleaseProfileDataValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty();
|
||||
RuleFor(x => x.TrashId).NotEmpty();
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.Required.Any() || x.Ignored.Any() || x.Preferred.Any())
|
||||
.WithMessage("Must have at least one of Required, Ignored, or Preferred terms");
|
||||
}
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace TrashLib.Sonarr.ReleaseProfile;
|
||||
|
||||
internal class TermDataConverter : JsonConverter
|
||||
{
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue,
|
||||
JsonSerializer serializer)
|
||||
{
|
||||
var token = JToken.Load(reader);
|
||||
return token.Type switch
|
||||
{
|
||||
JTokenType.Object => token.ToObject<TermData>(),
|
||||
JTokenType.String => new TermData {Term = token.ToString()},
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return objectType == typeof(TermData);
|
||||
}
|
||||
}
|
Loading…
Reference in new issue