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
commit
3ee7a8d866
@ -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();
|
||||
}
|
||||
}
|
@ -1,81 +1,25 @@
|
||||
using System.IO.Abstractions;
|
||||
using Common;
|
||||
using LibGit2Sharp;
|
||||
using Serilog;
|
||||
using TrashLib.Config.Settings;
|
||||
using TrashLib.Radarr.Config;
|
||||
using VersionControl;
|
||||
|
||||
namespace TrashLib.Radarr.CustomFormat.Guide;
|
||||
|
||||
internal class LocalRepoCustomFormatJsonParser : IRadarrGuideService
|
||||
public class LocalRepoCustomFormatJsonParser : IRadarrGuideService
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IGitRepositoryFactory _repositoryFactory;
|
||||
private readonly IFileUtilities _fileUtils;
|
||||
private readonly ISettingsProvider _settingsProvider;
|
||||
private readonly string _repoPath;
|
||||
private readonly IResourcePaths _paths;
|
||||
|
||||
public LocalRepoCustomFormatJsonParser(
|
||||
ILogger log,
|
||||
IFileSystem fileSystem,
|
||||
IResourcePaths paths,
|
||||
IGitRepositoryFactory repositoryFactory,
|
||||
IFileUtilities fileUtils,
|
||||
ISettingsProvider settingsProvider)
|
||||
public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IResourcePaths paths)
|
||||
{
|
||||
_log = log;
|
||||
_fileSystem = fileSystem;
|
||||
_repositoryFactory = repositoryFactory;
|
||||
_fileUtils = fileUtils;
|
||||
_settingsProvider = settingsProvider;
|
||||
_repoPath = paths.RepoPath;
|
||||
_paths = paths;
|
||||
}
|
||||
|
||||
public IEnumerable<string> GetCustomFormatJson()
|
||||
{
|
||||
// Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start
|
||||
// fresh.
|
||||
var exception = CheckoutAndUpdateRepo();
|
||||
if (exception is not null)
|
||||
{
|
||||
_log.Information("Deleting local git repo and retrying git operation...");
|
||||
_fileUtils.DeleteReadOnlyDirectory(_repoPath);
|
||||
|
||||
exception = CheckoutAndUpdateRepo();
|
||||
if (exception is not null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
var jsonDir = Path.Combine(_repoPath, "docs/json/radarr");
|
||||
var jsonDir = Path.Combine(_paths.RepoPath, "docs/json/radarr");
|
||||
var tasks = _fileSystem.Directory.GetFiles(jsonDir, "*.json")
|
||||
.Select(async f => await _fileSystem.File.ReadAllTextAsync(f));
|
||||
.Select(f => _fileSystem.File.ReadAllTextAsync(f));
|
||||
|
||||
return Task.WhenAll(tasks).Result;
|
||||
}
|
||||
|
||||
private Exception? CheckoutAndUpdateRepo()
|
||||
{
|
||||
var repoSettings = _settingsProvider.Settings.Repository;
|
||||
var cloneUrl = repoSettings.CloneUrl;
|
||||
const string branch = "master";
|
||||
|
||||
try
|
||||
{
|
||||
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, _repoPath, branch);
|
||||
repo.ForceCheckout(branch);
|
||||
repo.Fetch();
|
||||
repo.ResetHard($"origin/{branch}");
|
||||
}
|
||||
catch (LibGit2SharpException e)
|
||||
{
|
||||
_log.Error(e, "An exception occurred during git operations on path: {RepoPath}", _repoPath);
|
||||
return e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
namespace TrashLib.Repo;
|
||||
|
||||
public interface IRepoUpdater
|
||||
{
|
||||
string RepoPath { get; }
|
||||
void UpdateRepo();
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
using Common;
|
||||
using LibGit2Sharp;
|
||||
using Serilog;
|
||||
using TrashLib.Config.Settings;
|
||||
using TrashLib.Radarr.Config;
|
||||
using VersionControl;
|
||||
|
||||
namespace TrashLib.Repo;
|
||||
|
||||
public class RepoUpdater : IRepoUpdater
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
private readonly IGitRepositoryFactory _repositoryFactory;
|
||||
private readonly IFileUtilities _fileUtils;
|
||||
private readonly ISettingsProvider _settingsProvider;
|
||||
|
||||
public RepoUpdater(
|
||||
ILogger log,
|
||||
IResourcePaths paths,
|
||||
IGitRepositoryFactory repositoryFactory,
|
||||
IFileUtilities fileUtils,
|
||||
ISettingsProvider settingsProvider)
|
||||
{
|
||||
_log = log;
|
||||
_repositoryFactory = repositoryFactory;
|
||||
_fileUtils = fileUtils;
|
||||
_settingsProvider = settingsProvider;
|
||||
RepoPath = paths.RepoPath;
|
||||
}
|
||||
|
||||
public string RepoPath { get; }
|
||||
|
||||
public void UpdateRepo()
|
||||
{
|
||||
// Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start
|
||||
// fresh.
|
||||
var exception = CheckoutAndUpdateRepo();
|
||||
if (exception is not null)
|
||||
{
|
||||
_log.Information("Deleting local git repo and retrying git operation...");
|
||||
_fileUtils.DeleteReadOnlyDirectory(RepoPath);
|
||||
|
||||
exception = CheckoutAndUpdateRepo();
|
||||
if (exception is not null)
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Exception? CheckoutAndUpdateRepo()
|
||||
{
|
||||
var repoSettings = _settingsProvider.Settings.Repository;
|
||||
var cloneUrl = repoSettings.CloneUrl;
|
||||
const string branch = "master";
|
||||
|
||||
try
|
||||
{
|
||||
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, RepoPath, branch);
|
||||
repo.ForceCheckout(branch);
|
||||
repo.Fetch();
|
||||
repo.ResetHard($"origin/{branch}");
|
||||
}
|
||||
catch (LibGit2SharpException e)
|
||||
{
|
||||
_log.Error(e, "An exception occurred during git operations on path: {RepoPath}", RepoPath);
|
||||
return e;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
Special notes about behavior of Trash Updater with regards to the various services it supports will
|
||||
be documented here.
|
||||
|
||||
# Sonarr
|
||||
|
||||
Each section below represents a topic covering certain behavior relevant to Sonarr.
|
||||
|
||||
## Release Profile Naming
|
||||
|
||||
The script procedurally generates a name for release profiles it creates. For the following example:
|
||||
|
||||
```txt
|
||||
[Trash] Anime - First Release Profile
|
||||
```
|
||||
|
||||
The name is generated as follows:
|
||||
|
||||
- `[Trash]` is added by Trash Updater to indicate that this Release Profile is created and managed
|
||||
by it. This prefix exists to separate it from any Release Profiles the user may have manually
|
||||
created (which Trash Updater will not touch).
|
||||
- `Anime - First Release Profile` is the name of the Release Profile (taken from the `name` property
|
||||
of its corresponding JSON file).
|
@ -1,119 +0,0 @@
|
||||
In order for the `trash.py` script to remain as stable as possible between updates to the TRaSH
|
||||
guides, the following structural guidelines are provided. This document also serves as documentation
|
||||
on how the python script is implemented currently.
|
||||
|
||||
# Definitions
|
||||
|
||||
- **Term**<br>
|
||||
A phrase that is included in Sonarr release profiles under either the "Preferred", "Must Contain",
|
||||
or "Must Not Contain" sections. In the TRaSH guides these are regular expressions.
|
||||
|
||||
- **Ignored**<br>
|
||||
The API term for "Must Not Contain"
|
||||
|
||||
- **Required**<br>
|
||||
The API term for "Must Contain"
|
||||
|
||||
- **Category**<br>
|
||||
Refers to any of the different "sections" in a release profile where terms may be stored. Includes
|
||||
"Must Not Contain" (ignored), "Must Contain" (required), and "Preferred".
|
||||
|
||||
- **Mention**<br>
|
||||
This generally refers to any human-readable way of stating something that the script relies on for
|
||||
parsing purposes.
|
||||
|
||||
# Structural Guidelines
|
||||
|
||||
Different types of TRaSH guides are parsed in their own unique way, mostly because the data set is
|
||||
different. In order to ensure the script continues to be reliable, it's important that the structure
|
||||
of the guides do not change. The following sections outline various guidelines to help achieve this
|
||||
goal.
|
||||
|
||||
Note that all parsing happens directly on the markdown files themselves from the TRaSH github
|
||||
repository. Those files are processed one line at a time. Guidelines will apply on a per-line basis,
|
||||
unless otherwise stated.
|
||||
|
||||
The following general rules apply to all lines in the markdown data:
|
||||
|
||||
- Lines with leading whitespace (indentation) are skipped
|
||||
- Blank lines are skipped
|
||||
- Admonition lines (starting with `!!!` or `???`) are skipped.
|
||||
|
||||
## Sonarr Release Profiles
|
||||
|
||||
1. **Headers define release profiles.**
|
||||
|
||||
A header with the phrase `Release Profile` in it will start a new release profile. The header
|
||||
name may contain other keywords before or after that phrase, such as `First Release Profile`.
|
||||
This header name in its entirety will be used as part of the release profile name when the data
|
||||
is pushed to Sonarr.
|
||||
|
||||
1. **Fenced code blocks must *only* contain ignored, required, or preferred terms.**
|
||||
|
||||
Between headers, fenced code blocks indicate the terms that will be captured and pushed to Sonarr
|
||||
for any given type of category (required, preferred, or ignored). There may be more than one
|
||||
fenced code block, and each fenced code block may have more than one line inside of it. Each line
|
||||
inside of a fenced code block is treated as 1 single term. Commas at the end of each line are
|
||||
removed, if they are present.
|
||||
|
||||
1. **For preferred terms, a score must be mentioned prior to the first fenced code block.**
|
||||
|
||||
Each separate line in the markdown file is inspected for the word `score` followed by a number
|
||||
inside square brackets, such as `[100]`. If found, the score between the brackets is captured and
|
||||
applied to any future terms found within fenced code blocks. Between fenced code blocks under the
|
||||
same heading, a new score using these same rules may be mentioned to change it again.
|
||||
|
||||
Terms mentioned prior to a score being set are discarded.
|
||||
|
||||
1. **Categories shall be specified before the first fenced code block.**
|
||||
|
||||
Categories are technically optional; if one is never explicitly mentioned in the guide, the
|
||||
default is "Preferred". Depending on the category, certain requirements change. At the moment, if
|
||||
"Preferred" is used, this also requires a score. However "Must Not Contain" and "Must Contain" do
|
||||
not require a score.
|
||||
|
||||
A category must mentioned as one of the following phrases (case insensitive):
|
||||
|
||||
- `Preferred`
|
||||
- `Must Not Contain`
|
||||
- `Must Contain`
|
||||
|
||||
These phrases may appear in nested headers, normal lines, and may even appear inside the same
|
||||
line that defines a score (e.g. `Insert these as "Preferred" with a score of [100]`).
|
||||
|
||||
1. **"Include Preferred when Renaming" may be optionally set via mention.**
|
||||
|
||||
If you wish to control the checked/unchecked state of the "Include Preferred when Renaming"
|
||||
option in a release profile, simply mention the phrase `include preferred` (case-insensitive) on
|
||||
any single line. This marks it as "CHECKED". If it also finds the word `not` on that same line,
|
||||
it will instead be marked "UNCHECKED".
|
||||
|
||||
This is optional and the default is always "UNCHECKED".
|
||||
|
||||
1. **Terms may be marked "optional".**
|
||||
|
||||
From a header or sentence within a header section, the appearance of the word "optional" will
|
||||
indicate that certain terms will *not* be synchronized to Sonarr by default. The semantics for
|
||||
this differ depending on where the word is mentioned:
|
||||
|
||||
- **Headers**: "Optional" mentioned in a header will apply to all code blocks in that whole
|
||||
section. Furthermore, any nested headers will also be treated as if the word "Optional" appears
|
||||
in its name, even if it isn't.
|
||||
- **Sentence**: Once the term "Optional" is found in any sentence following a header, it applies
|
||||
to all code blocks for the remainder of that section. Once a new header is found (nested or
|
||||
not), terms are not considered optional anymore.
|
||||
|
||||
### Release Profile Naming
|
||||
|
||||
The script procedurally generates a name for release profiles it creates. For the following example:
|
||||
|
||||
```txt
|
||||
[Trash] Anime - First Release Profile
|
||||
```
|
||||
|
||||
The name is generated as follows:
|
||||
|
||||
- `Anime` comes from the guide type (could be `WEB-DL`)
|
||||
- `First Release Profile` is directly from one of the headers in the anime guide
|
||||
- `[Trash]` is used by the script to mean "This release profile is controlled by the script". This
|
||||
is to separate it from any manual ones the user has defined, which the script will not touch.
|
@ -0,0 +1,86 @@
|
||||
# Version 2.0
|
||||
|
||||
This version introduces changes to the way Sonarr Release Profiles are specified in your YAML
|
||||
configuration (`trash.yml`). As such, changes are required to your YAML to avoid errors. First,
|
||||
visit the "Series Types" section to replace the `type` attribute with `trash_ids` as needed. Then
|
||||
check out the "Term Filters" section to see about removing the `include_optionals` property.
|
||||
|
||||
## Series Types
|
||||
|
||||
The `type` property under `release_profiles` has been removed. Replaced by a new `trash_ids`
|
||||
property.
|
||||
|
||||
### Drop-In Replacement for Series
|
||||
|
||||
For `series`, replace this:
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- type: series
|
||||
```
|
||||
|
||||
With this (or you can customize it if you want less):
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- trash_ids:
|
||||
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
|
||||
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
|
||||
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
|
||||
```
|
||||
|
||||
### Drop-In Replacement for Anime
|
||||
|
||||
For `series`, replace this:
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- type: anime
|
||||
```
|
||||
|
||||
With this (or you can customize it if you want less):
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- trash_ids:
|
||||
- d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
|
||||
- 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
|
||||
```
|
||||
|
||||
## Term Filters
|
||||
|
||||
The following changes apply to YAML under the `filter` property.
|
||||
|
||||
- Property `include_optional` removed.
|
||||
- `include` and `exclude` properties added to explicitly choose terms to include or exclude,
|
||||
respectively.
|
||||
|
||||
### Replacement Examples
|
||||
|
||||
If you are coming from YAML like this:
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- trash_ids: [EBC725268D687D588A20CBC5F97E538B]
|
||||
strict_negative_scores: false
|
||||
filter:
|
||||
include_optional: true
|
||||
tags:
|
||||
- tv
|
||||
```
|
||||
|
||||
Simply remove the `include_optional` property above, to get this:
|
||||
|
||||
```yml
|
||||
release_profiles:
|
||||
- trash_ids: [EBC725268D687D588A20CBC5F97E538B]
|
||||
strict_negative_scores: false
|
||||
tags:
|
||||
- tv
|
||||
```
|
||||
|
||||
In this release, since you now have the ability to specifically include optionals that you want, I
|
||||
recommend visiting the [Configuration Reference] and learning more about the `include` and `exclude`
|
||||
filter lists.
|
||||
|
||||
[Configuration Reference]: https://github.com/rcdailey/trash-updater/wiki/Configuration-Reference
|
Loading…
Reference in new issue