diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a589b67..4951c3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Linux MUSL builds for arm, arm64, and x64. Main target for this was supporting Alpine Linux in - Docker. +This release contains **BREAKING CHANGES**. See the [Upgrade Guide] for required changes you need to +make. ### Changed +- **BREAKING**: Sonarr Release profiles are now synced based on a "Trash ID" taken from [the sonarr +JSON files][sonarrjson]. This breaks existing `trash.yml` and manual changes *are required*. - Do not follow HTTP redirects and instead issue a warning to the user that they are potentially using the wrong URL. - Radarr: Sanitize URLs in HTTP exception messages ([#17]). +- Sonarr: Release profiles starting with `[Trash]` but are not specified in the config are deleted. + +### Added + +- Linux MUSL builds for arm, arm64, and x64. Main target for this was supporting Alpine Linux in + Docker. +- Sonarr: Ability to include or exclude specific optional Required, Ignored, or Preferred terms in + release profiles. [#17]: https://github.com/rcdailey/trash-updater/issues/17 +[Upgrade Guide]: https://github.com/rcdailey/trash-updater/wiki/Upgrade-Guide +[sonarrjson]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarr ## [1.8.2] - 2022-03-06 diff --git a/src/Common/Extensions/FluentValidationExtensions.cs b/src/Common/Extensions/FluentValidationExtensions.cs deleted file mode 100644 index 8f005fc7..00000000 --- a/src/Common/Extensions/FluentValidationExtensions.cs +++ /dev/null @@ -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 SetNonNullableValidator( - this IRuleBuilder ruleBuilder, IValidator validator, params string[] ruleSets) - { - var adapter = new NullableChildValidatorAdaptor(validator, validator.GetType()) - { - RuleSets = ruleSets - }; - - return ruleBuilder.SetAsyncValidator(adapter); - } - - private sealed class NullableChildValidatorAdaptor : ChildValidatorAdaptor, - IPropertyValidator, IAsyncPropertyValidator - { - public NullableChildValidatorAdaptor(IValidator validator, Type validatorType) - : base(validator, validatorType) - { - } - - public override Task IsValidAsync(ValidationContext context, TProperty? value, - CancellationToken cancellation) - { - return base.IsValidAsync(context, value!, cancellation); - } - - public override bool IsValid(ValidationContext context, TProperty? value) - { - return base.IsValid(context, value!); - } - } -} diff --git a/src/Common/JsonNetExtensions.cs b/src/Common/Extensions/JsonNetExtensions.cs similarity index 94% rename from src/Common/JsonNetExtensions.cs rename to src/Common/Extensions/JsonNetExtensions.cs index 7a1a6654..825cb667 100644 --- a/src/Common/JsonNetExtensions.cs +++ b/src/Common/Extensions/JsonNetExtensions.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json.Linq; -namespace Common; +namespace Common.Extensions; public static class JsonNetExtensions { diff --git a/src/Common/Extensions/StringExtensions.cs b/src/Common/Extensions/StringExtensions.cs index ac215753..5856cbd0 100644 --- a/src/Common/Extensions/StringExtensions.cs +++ b/src/Common/Extensions/StringExtensions.cs @@ -4,14 +4,24 @@ namespace Common.Extensions; public static class StringExtensions { - public static bool ContainsIgnoreCase(this string value, string searchFor) + public static bool ContainsIgnoreCase(this string? value, string searchFor) { - return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase); + return value?.Contains(searchFor, StringComparison.OrdinalIgnoreCase) ?? false; } - public static bool EqualsIgnoreCase(this string value, string? matchThis) + public static bool EqualsIgnoreCase(this string? value, string? matchThis) { - return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase); + return value?.Equals(matchThis, StringComparison.OrdinalIgnoreCase) ?? false; + } + + public static bool EndsWithIgnoreCase(this string? value, string matchThis) + { + return value?.EndsWith(matchThis, StringComparison.OrdinalIgnoreCase) ?? false; + } + + public static bool StartsWithIgnoreCase(this string? value, string matchThis) + { + return value?.StartsWith(matchThis, StringComparison.OrdinalIgnoreCase) ?? false; } public static float ToFloat(this string value) diff --git a/src/Common/FluentValidation/FluentValidationExtensions.cs b/src/Common/FluentValidation/FluentValidationExtensions.cs new file mode 100644 index 00000000..1279828d --- /dev/null +++ b/src/Common/FluentValidation/FluentValidationExtensions.cs @@ -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 SetNonNullableValidator( + this IRuleBuilder ruleBuilder, IValidator validator, params string[] ruleSets) + { + var adapter = new NullableChildValidatorAdaptor(validator, validator.GetType()) + { + RuleSets = ruleSets + }; + + return ruleBuilder.SetAsyncValidator(adapter); + } + + public static IEnumerable IsValid( + this IEnumerable source, TValidator validator, + Action, TSource>? handleInvalid = null) + where TValidator : IValidator, new() + { + foreach (var s in source) + { + var result = validator.Validate(s); + if (result.IsValid) + { + yield return s; + } + else + { + handleInvalid?.Invoke(result.Errors, s); + } + } + } +} diff --git a/src/Common/FluentValidation/NullableChildValidatorAdaptor.cs b/src/Common/FluentValidation/NullableChildValidatorAdaptor.cs new file mode 100644 index 00000000..e73fd08e --- /dev/null +++ b/src/Common/FluentValidation/NullableChildValidatorAdaptor.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using FluentValidation.Validators; + +namespace Common.FluentValidation; + +internal sealed class NullableChildValidatorAdaptor : ChildValidatorAdaptor, + IPropertyValidator, IAsyncPropertyValidator +{ + public NullableChildValidatorAdaptor(IValidator validator, Type validatorType) + : base(validator, validatorType) + { + } + + public override Task IsValidAsync(ValidationContext context, TProperty? value, + CancellationToken cancellation) + { + return base.IsValidAsync(context, value!, cancellation); + } + + public override bool IsValid(ValidationContext context, TProperty? value) + { + return base.IsValid(context, value!); + } +} diff --git a/src/Common/YamlDotNet/ReadOnlyCollectionNodeTypeResolver.cs b/src/Common/YamlDotNet/ReadOnlyCollectionNodeTypeResolver.cs new file mode 100644 index 00000000..84725d92 --- /dev/null +++ b/src/Common/YamlDotNet/ReadOnlyCollectionNodeTypeResolver.cs @@ -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 CustomGenericInterfaceImplementations = + new Dictionary + { + {typeof(IReadOnlyCollection<>), typeof(List<>)}, + {typeof(IReadOnlyList<>), typeof(List<>)}, + {typeof(IReadOnlyDictionary<,>), typeof(Dictionary<,>)} + }; +} diff --git a/src/Trash.Tests/Config/Services/ConfigurationLoaderTest.cs b/src/Trash.Tests/Config/Services/ConfigurationLoaderTest.cs index 15d43b58..339ea07d 100644 --- a/src/Trash.Tests/Config/Services/ConfigurationLoaderTest.cs +++ b/src/Trash.Tests/Config/Services/ConfigurationLoaderTest.cs @@ -18,7 +18,6 @@ using Trash.Config; using TrashLib.Config; using TrashLib.Config.Services; using TrashLib.Sonarr.Config; -using TrashLib.Sonarr.ReleaseProfile; using YamlDotNet.Serialization; using YamlDotNet.Serialization.ObjectFactories; @@ -105,13 +104,13 @@ public class ConfigurationLoaderTest { new() { - Type = ReleaseProfileType.Anime, + TrashIds = new[] {"123"}, StrictNegativeScores = true, Tags = new List {"anime"} }, new() { - Type = ReleaseProfileType.Series, + TrashIds = new[] {"456"}, StrictNegativeScores = false, Tags = new List { diff --git a/src/Trash.Tests/Config/Services/Data/Load_UsingStream_CorrectParsing.yml b/src/Trash.Tests/Config/Services/Data/Load_UsingStream_CorrectParsing.yml index 8a167c41..4db0fb2f 100644 --- a/src/Trash.Tests/Config/Services/Data/Load_UsingStream_CorrectParsing.yml +++ b/src/Trash.Tests/Config/Services/Data/Load_UsingStream_CorrectParsing.yml @@ -2,11 +2,11 @@ - base_url: http://localhost:8989 api_key: 95283e6b156c42f3af8a9b16173f876b release_profiles: - - type: anime + - trash_ids: [123] strict_negative_scores: true tags: - anime - - type: series + - trash_ids: [456] tags: - tv - series diff --git a/src/Trash/Command/RadarrCommand.cs b/src/Trash/Command/RadarrCommand.cs index f29d33a3..c2fdf781 100644 --- a/src/Trash/Command/RadarrCommand.cs +++ b/src/Trash/Command/RadarrCommand.cs @@ -9,6 +9,7 @@ using TrashLib.Extensions; using TrashLib.Radarr.Config; using TrashLib.Radarr.CustomFormat; using TrashLib.Radarr.QualityDefinition; +using TrashLib.Repo; namespace Trash.Command; @@ -27,10 +28,11 @@ public class RadarrCommand : ServiceCommand ILogJanitor logJanitor, ISettingsPersister settingsPersister, ISettingsProvider settingsProvider, + IRepoUpdater repoUpdater, IConfigurationLoader configLoader, Func qualityUpdaterFactory, Func customFormatUpdaterFactory) - : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider) + : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater) { _log = log; _configLoader = configLoader; @@ -41,7 +43,7 @@ public class RadarrCommand : ServiceCommand public override string CacheStoragePath { get; } = Path.Combine(AppPaths.AppDataPath, "cache", "radarr"); - public override async Task Process() + protected override async Task Process() { try { diff --git a/src/Trash/Command/ServiceCommand.cs b/src/Trash/Command/ServiceCommand.cs index 3ce81ddc..246daee0 100644 --- a/src/Trash/Command/ServiceCommand.cs +++ b/src/Trash/Command/ServiceCommand.cs @@ -12,6 +12,7 @@ using Serilog.Core; using Serilog.Events; using TrashLib.Config.Settings; using TrashLib.Extensions; +using TrashLib.Repo; using YamlDotNet.Core; namespace Trash.Command; @@ -23,30 +24,50 @@ public abstract class ServiceCommand : ICommand, IServiceCommand private readonly ILogJanitor _logJanitor; private readonly ISettingsPersister _settingsPersister; private readonly ISettingsProvider _settingsProvider; + private readonly IRepoUpdater _repoUpdater; + + [CommandOption("preview", 'p', Description = + "Only display the processed markdown results without making any API calls.")] + public bool Preview { get; [UsedImplicitly] set; } = false; + + [CommandOption("debug", 'd', Description = + "Display additional logs useful for development/debug purposes.")] + public bool Debug { get; [UsedImplicitly] set; } = false; + + [CommandOption("config", 'c', Description = + "One or more YAML config files to use. All configs will be used and settings are additive. " + + "If not specified, the script will look for `trash.yml` in the same directory as the executable.")] + public ICollection Config { get; [UsedImplicitly] set; } = + new List {AppPaths.DefaultConfigPath}; + + public abstract string CacheStoragePath { get; } protected ServiceCommand( ILogger log, LoggingLevelSwitch loggingLevelSwitch, ILogJanitor logJanitor, ISettingsPersister settingsPersister, - ISettingsProvider settingsProvider) + ISettingsProvider settingsProvider, + IRepoUpdater repoUpdater) { _loggingLevelSwitch = loggingLevelSwitch; _logJanitor = logJanitor; _settingsPersister = settingsPersister; _settingsProvider = settingsProvider; + _repoUpdater = repoUpdater; _log = log; } public async ValueTask ExecuteAsync(IConsole console) { // Must happen first because everything can use the logger. - SetupLogging(); + _loggingLevelSwitch.MinimumLevel = Debug ? LogEventLevel.Debug : LogEventLevel.Information; // Has to happen right after logging because stuff below may use settings. - LoadSettings(); + _settingsPersister.Load(); SetupHttp(); + _repoUpdater.UpdateRepo(); try { @@ -71,42 +92,10 @@ public abstract class ServiceCommand : ICommand, IServiceCommand } finally { - CleanupOldLogFiles(); + _logJanitor.DeleteOldestLogFiles(20); } } - private void LoadSettings() - { - _settingsPersister.Load(); - } - - [CommandOption("preview", 'p', Description = - "Only display the processed markdown results without making any API calls.")] - public bool Preview { get; [UsedImplicitly] set; } = false; - - [CommandOption("debug", 'd', Description = - "Display additional logs useful for development/debug purposes.")] - public bool Debug { get; [UsedImplicitly] set; } = false; - - [CommandOption("config", 'c', Description = - "One or more YAML config files to use. All configs will be used and settings are additive. " + - "If not specified, the script will look for `trash.yml` in the same directory as the executable.")] - public ICollection Config { get; [UsedImplicitly] set; } = - new List {AppPaths.DefaultConfigPath}; - - public abstract string CacheStoragePath { get; } - - private void CleanupOldLogFiles() - { - _logJanitor.DeleteOldestLogFiles(20); - } - - private void SetupLogging() - { - _loggingLevelSwitch.MinimumLevel = - Debug ? LogEventLevel.Debug : LogEventLevel.Information; - } - private void SetupHttp() { FlurlHttp.Configure(settings => @@ -135,7 +124,7 @@ public abstract class ServiceCommand : ICommand, IServiceCommand }); } - public abstract Task Process(); + protected abstract Task Process(); protected static void ExitDueToFailure() { diff --git a/src/Trash/Command/SonarrCommand.cs b/src/Trash/Command/SonarrCommand.cs index 4918168f..f23a5512 100644 --- a/src/Trash/Command/SonarrCommand.cs +++ b/src/Trash/Command/SonarrCommand.cs @@ -5,6 +5,7 @@ using Serilog; using Serilog.Core; using Trash.Config; using TrashLib.Config.Settings; +using TrashLib.Repo; using TrashLib.Sonarr.Config; using TrashLib.Sonarr.QualityDefinition; using TrashLib.Sonarr.ReleaseProfile; @@ -26,10 +27,11 @@ public class SonarrCommand : ServiceCommand ILogJanitor logJanitor, ISettingsPersister settingsPersister, ISettingsProvider settingsProvider, + IRepoUpdater repoUpdater, IConfigurationLoader configLoader, Func profileUpdaterFactory, Func qualityUpdaterFactory) - : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider) + : base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater) { _log = log; _configLoader = configLoader; @@ -40,7 +42,7 @@ public class SonarrCommand : ServiceCommand public override string CacheStoragePath { get; } = Path.Combine(AppPaths.AppDataPath, "cache", "sonarr"); - public override async Task Process() + protected override async Task Process() { try { diff --git a/src/Trash/trash-config-template.yml b/src/Trash/trash-config-template.yml index 376d88c5..22d62149 100644 --- a/src/Trash/trash-config-template.yml +++ b/src/Trash/trash-config-template.yml @@ -16,11 +16,18 @@ sonarr: # Quality definitions from the guide to sync to Sonarr. Choice: anime, series, hybrid quality_definition: hybrid - # Release profiles from the guide to sync to Sonarr. Types: anime, series + # Release profiles from the guide to sync to Sonarr. # You can optionally add tags and make negative scores strictly ignored release_profiles: - - type: anime - - type: series + # Series + - trash_ids: + - EBC725268D687D588A20CBC5F97E538B # Low Quality Groups + - 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service) + - 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper + # Anime (Uncomment below if you want it) +# - trash_ids: +# - d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile +# - 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile # Configuration specific to Radarr. radarr: diff --git a/src/TrashLib.Tests/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParserTest.cs b/src/TrashLib.Tests/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParserTest.cs new file mode 100644 index 00000000..5ec8445e --- /dev/null +++ b/src/TrashLib.Tests/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParserTest.cs @@ -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"); + } +} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/FilteredProfileDataTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/FilteredProfileDataTest.cs deleted file mode 100644 index afe0bada..00000000 --- a/src/TrashLib.Tests/Sonarr/ReleaseProfile/FilteredProfileDataTest.cs +++ /dev/null @@ -1,90 +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 FilteredProfileDataTest -{ - [Test] - public void Filter_ExcludeOptional_HasNoOptionalItems() - { - var config = new ReleaseProfileConfig(); - config.Filter.IncludeOptional = false; - - var profileData = new ProfileData - { - Ignored = new List {"ignored1"}, - Required = new List {"required1"}, - Preferred = new Dictionary> - { - {100, new List {"preferred1"}} - }, - Optional = new ProfileDataOptional - { - Ignored = new List {"ignored2"}, - Required = new List {"required2"}, - Preferred = new Dictionary> - { - {200, new List {"preferred2"}}, - {100, new List {"preferred3"}} - } - } - }; - - var filtered = new FilteredProfileData(profileData, config); - - filtered.Should().BeEquivalentTo(new - { - Ignored = new List {"ignored1"}, - Required = new List {"required1"}, - Preferred = new Dictionary> - { - {100, new List {"preferred1"}} - } - }); - } - - [Test] - public void Filter_IncludeOptional_HasAllOptionalItems() - { - var config = new ReleaseProfileConfig(); - config.Filter.IncludeOptional = true; - - var profileData = new ProfileData - { - Ignored = new List {"ignored1"}, - Required = new List {"required1"}, - Preferred = new Dictionary> - { - {100, new List {"preferred1"}} - }, - Optional = new ProfileDataOptional - { - Ignored = new List {"ignored2"}, - Required = new List {"required2"}, - Preferred = new Dictionary> - { - {200, new List {"preferred2"}}, - {100, new List {"preferred3"}} - } - } - }; - - var filtered = new FilteredProfileData(profileData, config); - - filtered.Should().BeEquivalentTo(new - { - Ignored = new List {"ignored1", "ignored2"}, - Required = new List {"required1", "required2"}, - Preferred = new Dictionary> - { - {100, new List {"preferred1", "preferred3"}}, - {200, new List {"preferred2"}} - } - }); - } -} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParserTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParserTest.cs new file mode 100644 index 00000000..f440453a --- /dev/null +++ b/src/TrashLib.Tests/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParserTest.cs @@ -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 + }); + } +} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataFiltererTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataFiltererTest.cs new file mode 100644 index 00000000..bcf78256 --- /dev/null +++ b/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataFiltererTest.cs @@ -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"} + } + } + } + }); + } +} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataValidatorTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataValidatorTest.cs new file mode 100644 index 00000000..46b60b54 --- /dev/null +++ b/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataValidatorTest.cs @@ -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(), + Ignored = Array.Empty(), + 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(), + Preferred = Array.Empty() + }; + + 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(), + Ignored = new[] {new TermData {Term = "term"}}, + Preferred = Array.Empty() + }; + + 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); + } +} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs deleted file mode 100644 index 5ab9d7bd..00000000 --- a/src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs +++ /dev/null @@ -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 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 {"abc"}, - Required = new List {"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 {"abc"}, - Required = new List {"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 {"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 - { - { - "Release Profile 1", new ProfileData - { - IncludePreferredWhenRenaming = false, - Required = new List {"test1"} - } - }, - { - "Release Profile 2", new ProfileData - { - IncludePreferredWhenRenaming = true, - Required = new List {"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 - { - { - "Optional Release Profile", new ProfileData - { - Optional = new ProfileDataOptional - { - Ignored = new List {"optional1"}, - Required = new List {"optional3"}, - Preferred = new Dictionary> - { - {10, new List {"optional2"}} - } - } - } - }, - { - "Second Release Profile", new ProfileData - { - Ignored = new List {"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> - { - {100, new List {"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 - { - "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 - { - 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 {"abc"}, - Preferred = new Dictionary> {{0, new List {"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 - { - { - "Test Release Profile", new ProfileData - { - Ignored = new List {"added1"}, - Preferred = new Dictionary> - { - {10, new List {"added2", "added3"}} - } - } - } - }; - - results.Should().BeEquivalentTo(expectedResults); - } -} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfile/UtilsTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfile/UtilsTest.cs deleted file mode 100644 index cb90479f..00000000 --- a/src/TrashLib.Tests/Sonarr/ReleaseProfile/UtilsTest.cs +++ /dev/null @@ -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 {"term"}}; - var data = new Dictionary {{"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 {"term"}}; - var data = new Dictionary {{"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> - { - {100, new List {"term"}} - } - }; - - var data = new Dictionary {{"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 {"term"} - } - }; - - var data = new Dictionary {{"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 {"required1"} - } - }; - - var data = new Dictionary - { - {"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> - { - {100, new List {"term"}} - } - } - }; - - var data = new Dictionary {{"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 - { - {"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> - { - {100, new List {"term"}} - } - } - }; - - var data = new Dictionary {{"actualData", profileData}}; - - var filteredData = Utils.FilterProfiles(data, _filterExcludeOptional); - - filteredData.Should().BeEmpty(); - } -} diff --git a/src/TrashLib.Tests/Sonarr/ReleaseProfileUpdaterTest.cs b/src/TrashLib.Tests/Sonarr/ReleaseProfileUpdaterTest.cs deleted file mode 100644 index 23cd2564..00000000 --- a/src/TrashLib.Tests/Sonarr/ReleaseProfileUpdaterTest.cs +++ /dev/null @@ -1,50 +0,0 @@ -using NSubstitute; -using NUnit.Framework; -using Serilog; -using TrashLib.Sonarr; -using TrashLib.Sonarr.Api; -using TrashLib.Sonarr.Config; -using TrashLib.Sonarr.ReleaseProfile; - -namespace TrashLib.Tests.Sonarr; - -[TestFixture] -[Parallelizable(ParallelScope.All)] -public class ReleaseProfileUpdaterTest -{ - private class Context - { - public IReleaseProfileGuideParser Parser { get; } = Substitute.For(); - public ISonarrApi Api { get; } = Substitute.For(); - public ILogger Logger { get; } = Substitute.For(); - public ISonarrCompatibility Compatibility { get; } = Substitute.For(); - } - - [Test] - public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls() - { - var context = new Context(); - - var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility); - logic.Process(false, new SonarrConfiguration()); - - context.Parser.DidNotReceive().GetMarkdownData(Arg.Any()); - } - - [Test] - public void ProcessReleaseProfile_SingleProfilePreview() - { - var context = new Context(); - - context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown"); - var config = new SonarrConfiguration - { - ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}} - }; - - var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility); - logic.Process(false, config); - - context.Parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown"); - } -} diff --git a/src/TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs b/src/TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs index 8e41ca6d..323e1ae8 100644 --- a/src/TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs +++ b/src/TrashLib.Tests/Sonarr/SonarrConfigurationTest.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using TrashLib.Config; using TrashLib.Sonarr; using TrashLib.Sonarr.Config; -using TrashLib.Sonarr.ReleaseProfile; namespace TrashLib.Tests.Sonarr; @@ -28,21 +27,27 @@ public class SonarrConfigurationTest public void Validation_fails_for_all_missing_required_properties() { // default construct which should yield default values (invalid) for all required properties - var config = new SonarrConfiguration(); + var config = new SonarrConfiguration + { + // validation is only applied to actual release profile elements. Not if it's empty. + ReleaseProfiles = new[] {new ReleaseProfileConfig()} + }; + var validator = _container.Resolve>(); var result = validator.Validate(config); + var messages = new SonarrValidationMessages(); var expectedErrorMessageSubstrings = new[] { - "Property 'base_url' is required", - "Property 'api_key' is required", - "'type' is required for 'release_profiles' elements" + messages.ApiKey, + messages.BaseUrl, + messages.ReleaseProfileTrashIds }; result.IsValid.Should().BeFalse(); - result.Errors.Select(e => e.ErrorMessage).Should() - .OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains)); + result.Errors.Select(e => e.ErrorMessage) + .Should().BeEquivalentTo(expectedErrorMessageSubstrings); } [Test] @@ -54,7 +59,7 @@ public class SonarrConfigurationTest BaseUrl = "required value", ReleaseProfiles = new List { - new() {Type = ReleaseProfileType.Anime} + new() {TrashIds = new[] {"123"}} } }; diff --git a/src/TrashLib/Config/YamlSerializerFactory.cs b/src/TrashLib/Config/YamlSerializerFactory.cs index 51294411..fa5645bd 100644 --- a/src/TrashLib/Config/YamlSerializerFactory.cs +++ b/src/TrashLib/Config/YamlSerializerFactory.cs @@ -18,6 +18,7 @@ public class YamlSerializerFactory : IYamlSerializerFactory return new DeserializerBuilder() .WithNamingConvention(UnderscoredNamingConvention.Instance) .WithTypeConverter(new YamlNullableEnumTypeConverter()) + .WithNodeTypeResolver(new ReadOnlyCollectionNodeTypeResolver()) .WithObjectFactory(_objectFactory) .Build(); } diff --git a/src/TrashLib/Extensions/FlurlLogging.cs b/src/TrashLib/Extensions/FlurlLogging.cs index 92065abb..cac1064d 100644 --- a/src/TrashLib/Extensions/FlurlLogging.cs +++ b/src/TrashLib/Extensions/FlurlLogging.cs @@ -13,14 +13,14 @@ public static class FlurlLogging settings.BeforeCall = call => { var url = urlInterceptor(call.Request.Url.Clone()); - log.Debug("HTTP Request to {Url}", url); + log.Debug("HTTP Request: {Method} {Url}", call.HttpRequestMessage.Method, url); }; settings.AfterCall = call => { var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)"; var url = urlInterceptor(call.Request.Url.Clone()); - log.Debug("HTTP Response {Status} from {Url}", statusCode, url); + log.Debug("HTTP Response: {Status} {Method} {Url}", statusCode, call.HttpRequestMessage.Method, url); }; settings.OnRedirect = call => diff --git a/src/TrashLib/Radarr/Config/RadarrConfigurationValidator.cs b/src/TrashLib/Radarr/Config/RadarrConfigurationValidator.cs index 4fc00d0d..ef3f12f9 100644 --- a/src/TrashLib/Radarr/Config/RadarrConfigurationValidator.cs +++ b/src/TrashLib/Radarr/Config/RadarrConfigurationValidator.cs @@ -1,4 +1,4 @@ -using Common.Extensions; +using Common.FluentValidation; using FluentValidation; using JetBrains.Annotations; diff --git a/src/TrashLib/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParser.cs b/src/TrashLib/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParser.cs index 6b14ea31..855af5e2 100644 --- a/src/TrashLib/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParser.cs +++ b/src/TrashLib/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParser.cs @@ -1,26 +1,24 @@ using System.IO.Abstractions; -using TrashLib.Repo; +using TrashLib.Radarr.Config; namespace TrashLib.Radarr.CustomFormat.Guide; -internal class LocalRepoCustomFormatJsonParser : IRadarrGuideService +public class LocalRepoCustomFormatJsonParser : IRadarrGuideService { private readonly IFileSystem _fileSystem; - private readonly IRepoUpdater _repoUpdater; + private readonly IResourcePaths _paths; - public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IRepoUpdater repoUpdater) + public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IResourcePaths paths) { _fileSystem = fileSystem; - _repoUpdater = repoUpdater; + _paths = paths; } public IEnumerable GetCustomFormatJson() { - _repoUpdater.UpdateRepo(); - - var jsonDir = Path.Combine(_repoUpdater.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; } diff --git a/src/TrashLib/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs b/src/TrashLib/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs index 5aa4712d..ff9cee3a 100644 --- a/src/TrashLib/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs +++ b/src/TrashLib/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs @@ -1,4 +1,3 @@ -using Common; using Common.Extensions; using Newtonsoft.Json.Linq; using TrashLib.Radarr.Config; diff --git a/src/TrashLib/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs b/src/TrashLib/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs index d7b883ae..4ab75bab 100644 --- a/src/TrashLib/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs +++ b/src/TrashLib/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs @@ -1,4 +1,3 @@ -using Common; using Common.Extensions; using Newtonsoft.Json.Linq; using TrashLib.Radarr.CustomFormat.Api; diff --git a/src/TrashLib/Sonarr/Api/ISonarrApi.cs b/src/TrashLib/Sonarr/Api/ISonarrApi.cs index 2650e1d3..b084ca01 100644 --- a/src/TrashLib/Sonarr/Api/ISonarrApi.cs +++ b/src/TrashLib/Sonarr/Api/ISonarrApi.cs @@ -9,6 +9,7 @@ public interface ISonarrApi Task> GetReleaseProfiles(); Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate); Task CreateReleaseProfile(SonarrReleaseProfile newProfile); + Task DeleteReleaseProfile(int releaseProfileId); Task> GetQualityDefinition(); Task> UpdateQualityDefinition( diff --git a/src/TrashLib/Sonarr/Api/SonarrApi.cs b/src/TrashLib/Sonarr/Api/SonarrApi.cs index 20a428e4..c82cd050 100644 --- a/src/TrashLib/Sonarr/Api/SonarrApi.cs +++ b/src/TrashLib/Sonarr/Api/SonarrApi.cs @@ -68,6 +68,13 @@ public class SonarrApi : ISonarrApi return _profileHandler.CompatibleReleaseProfileForReceiving(response); } + public async Task DeleteReleaseProfile(int releaseProfileId) + { + await BaseUrl() + .AppendPathSegment($"releaseprofile/{releaseProfileId}") + .DeleteAsync(); + } + public async Task> GetQualityDefinition() { return await BaseUrl() diff --git a/src/TrashLib/Sonarr/Config/ISonarrValidationMessages.cs b/src/TrashLib/Sonarr/Config/ISonarrValidationMessages.cs index 0268f3fd..480a8c47 100644 --- a/src/TrashLib/Sonarr/Config/ISonarrValidationMessages.cs +++ b/src/TrashLib/Sonarr/Config/ISonarrValidationMessages.cs @@ -4,5 +4,5 @@ public interface ISonarrValidationMessages { string BaseUrl { get; } string ApiKey { get; } - string ReleaseProfileType { get; } + string ReleaseProfileTrashIds { get; } } diff --git a/src/TrashLib/Sonarr/Config/SonarrConfiguration.cs b/src/TrashLib/Sonarr/Config/SonarrConfiguration.cs index c8ceac70..b969705e 100644 --- a/src/TrashLib/Sonarr/Config/SonarrConfiguration.cs +++ b/src/TrashLib/Sonarr/Config/SonarrConfiguration.cs @@ -1,29 +1,24 @@ using TrashLib.Config.Services; using TrashLib.Sonarr.QualityDefinition; -using TrashLib.Sonarr.ReleaseProfile; namespace TrashLib.Sonarr.Config; public class SonarrConfiguration : ServiceConfiguration { - public IList ReleaseProfiles { get; set; } = new List(); + public IList ReleaseProfiles { get; init; } = Array.Empty(); public SonarrQualityDefinitionType? QualityDefinition { get; init; } } public class ReleaseProfileConfig { - // -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML - // all of this craziness is to avoid making the enum type nullable which will make using the property - // frustrating. - public ReleaseProfileType Type { get; init; } = (ReleaseProfileType) (-1); - + public IReadOnlyCollection TrashIds { get; init; } = Array.Empty(); public bool StrictNegativeScores { get; init; } - public SonarrProfileFilterConfig Filter { get; init; } = new(); - public ICollection Tags { get; init; } = new List(); + public IReadOnlyCollection Tags { get; init; } = Array.Empty(); + public SonarrProfileFilterConfig? Filter { get; init; } } public class SonarrProfileFilterConfig { - public bool IncludeOptional { get; set; } - // todo: Add Include & Exclude later (list of strings) + public IReadOnlyCollection Include { get; init; } = Array.Empty(); + public IReadOnlyCollection Exclude { get; init; } = Array.Empty(); } diff --git a/src/TrashLib/Sonarr/Config/SonarrConfigurationValidator.cs b/src/TrashLib/Sonarr/Config/SonarrConfigurationValidator.cs index 32b5f52c..b932671c 100644 --- a/src/TrashLib/Sonarr/Config/SonarrConfigurationValidator.cs +++ b/src/TrashLib/Sonarr/Config/SonarrConfigurationValidator.cs @@ -21,6 +21,6 @@ internal class ReleaseProfileConfigValidator : AbstractValidator x.Type).IsInEnum().WithMessage(messages.ReleaseProfileType); + RuleFor(x => x.TrashIds).NotEmpty().WithMessage(messages.ReleaseProfileTrashIds); } } diff --git a/src/TrashLib/Sonarr/Config/SonarrValidationMessages.cs b/src/TrashLib/Sonarr/Config/SonarrValidationMessages.cs index 12610550..7835c232 100644 --- a/src/TrashLib/Sonarr/Config/SonarrValidationMessages.cs +++ b/src/TrashLib/Sonarr/Config/SonarrValidationMessages.cs @@ -11,6 +11,6 @@ internal class SonarrValidationMessages : ISonarrValidationMessages public string ApiKey => "Property 'api_key' is required"; - public string ReleaseProfileType => - "'type' is required for 'release_profiles' elements"; + public string ReleaseProfileTrashIds => + "'trash_ids' is required for 'release_profiles' elements"; } diff --git a/src/TrashLib/Sonarr/ReleaseProfile/FilteredProfileData.cs b/src/TrashLib/Sonarr/ReleaseProfile/FilteredProfileData.cs deleted file mode 100644 index 7bc540c7..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/FilteredProfileData.cs +++ /dev/null @@ -1,32 +0,0 @@ -using TrashLib.Sonarr.Config; - -namespace TrashLib.Sonarr.ReleaseProfile; - -public class FilteredProfileData -{ - private readonly ReleaseProfileConfig _config; - private readonly ProfileData _profileData; - - public FilteredProfileData(ProfileData profileData, ReleaseProfileConfig config) - { - _profileData = profileData; - _config = config; - } - - public IEnumerable Required => _config.Filter.IncludeOptional - ? _profileData.Required.Concat(_profileData.Optional.Required).ToList() - : _profileData.Required; - - public IEnumerable Ignored => _config.Filter.IncludeOptional - ? _profileData.Ignored.Concat(_profileData.Optional.Ignored).ToList() - : _profileData.Ignored; - - public IDictionary> Preferred => _config.Filter.IncludeOptional - ? _profileData.Preferred - .Union(_profileData.Optional.Preferred) - .GroupBy(kvp => kvp.Key) - .ToDictionary(grp => grp.Key, grp => new List(grp.SelectMany(l => l.Value))) - : _profileData.Preferred; - - public bool? IncludePreferredWhenRenaming => _profileData.IncludePreferredWhenRenaming; -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/Guide/ISonarrGuideService.cs b/src/TrashLib/Sonarr/ReleaseProfile/Guide/ISonarrGuideService.cs new file mode 100644 index 00000000..3b0aab72 --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/Guide/ISonarrGuideService.cs @@ -0,0 +1,6 @@ +namespace TrashLib.Sonarr.ReleaseProfile.Guide; + +public interface ISonarrGuideService +{ + IReadOnlyCollection GetReleaseProfileData(); +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParser.cs b/src/TrashLib/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParser.cs new file mode 100644 index 00000000..df3f9b9e --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParser.cs @@ -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 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(json, converter); + }); + + return Task.WhenAll(tasks).Result + .Choose(x => x is not null ? (true, x) : default) // Make non-nullable type + .IsValid(new ReleaseProfileDataValidator()) + .ToList(); + } +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs b/src/TrashLib/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs deleted file mode 100644 index b99adb50..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs +++ /dev/null @@ -1,9 +0,0 @@ -using TrashLib.Sonarr.Config; - -namespace TrashLib.Sonarr.ReleaseProfile; - -public interface IReleaseProfileGuideParser -{ - Task GetMarkdownData(ReleaseProfileType profileName); - IDictionary ParseMarkdown(ReleaseProfileConfig config, string markdown); -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ParserState.cs b/src/TrashLib/Sonarr/ReleaseProfile/ParserState.cs deleted file mode 100644 index 0e3dca4f..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/ParserState.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Common.Extensions; -using Serilog; - -namespace TrashLib.Sonarr.ReleaseProfile; - -public enum TermCategory -{ - Required, - Ignored, - Preferred -} - -public class ParserState -{ - public ParserState(ILogger logger) - { - Log = logger; - ResetParserState(); - } - - private ILogger Log { get; } - public string? ProfileName { get; set; } - public int? Score { get; set; } - public ScopedState CurrentCategory { get; } = new(); - public bool InsideCodeBlock { get; set; } - public int ProfileHeaderDepth { get; set; } - public int CurrentHeaderDepth { get; set; } - public int LineNumber { get; set; } - public IDictionary Results { get; } = new Dictionary(); - - // If null, then terms are not considered optional - public ScopedState TermsAreOptional { get; } = new(); - - public bool IsValid => ProfileName != null && CurrentCategory.Value != null && - // If category is preferred, we also require a score - (CurrentCategory.Value != TermCategory.Preferred || Score != null); - - public ICollection IgnoredTerms - => TermsAreOptional.Value ? GetProfile().Optional.Ignored : GetProfile().Ignored; - - public ICollection RequiredTerms - => TermsAreOptional.Value ? GetProfile().Optional.Required : GetProfile().Required; - - public IDictionary> PreferredTerms - => TermsAreOptional.Value ? GetProfile().Optional.Preferred : GetProfile().Preferred; - - public ProfileData GetProfile() - { - if (ProfileName == null) - { - throw new NullReferenceException(); - } - - return Results.GetOrCreate(ProfileName); - } - - public void ResetParserState() - { - ProfileName = null; - Score = null; - InsideCodeBlock = false; - ProfileHeaderDepth = -1; - } - - public void ResetScopeState(int scope) - { - if (CurrentCategory.Reset(scope)) - { - Log.Debug(" - Reset Category State for Scope: {Scope}", scope); - } - - if (TermsAreOptional.Reset(scope)) - { - Log.Debug(" - Reset Optional State for Scope: {Scope}", scope); - } - } -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ProfileData.cs b/src/TrashLib/Sonarr/ReleaseProfile/ProfileData.cs deleted file mode 100644 index b893bc38..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/ProfileData.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace TrashLib.Sonarr.ReleaseProfile; - -public record ProfileDataOptional -{ - public ICollection Required { get; init; } = new List(); - public ICollection Ignored { get; init; } = new List(); - public IDictionary> Preferred { get; init; } = new Dictionary>(); -} - -public record ProfileData -{ - public ICollection Required { get; init; } = new List(); - public ICollection Ignored { get; init; } = new List(); - public IDictionary> Preferred { get; init; } = new Dictionary>(); - - // We use 'null' here to represent no explicit mention of the "include preferred" string - // found in the markdown. We use this to control whether or not the corresponding profile - // section gets printed in the first place, or if we modify the existing setting for - // existing profiles on the server. - public bool? IncludePreferredWhenRenaming { get; set; } - - public ProfileDataOptional Optional { get; init; } = new(); -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileData.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileData.cs new file mode 100644 index 00000000..632d1aae --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileData.cs @@ -0,0 +1,60 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace TrashLib.Sonarr.ReleaseProfile; + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record TermData +{ + [JsonProperty("trash_id")] + public string TrashId { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + public string Term { get; init; } = string.Empty; + + public override string ToString() + { + return $"[TrashId: {TrashId}] [Name: {Name}] [Term: {Term}]"; + } +} + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record PreferredTermData +{ + public int Score { get; init; } + public IReadOnlyCollection Terms { get; init; } = Array.Empty(); + + public void Deconstruct(out int score, out IReadOnlyCollection terms) + { + score = Score; + terms = Terms; + } + + public override string ToString() + { + return $"[Score: {Score}] [Terms: {Terms.Count}]"; + } +} + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record ReleaseProfileData +{ + [JsonProperty("trash_id")] + public string TrashId { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + public bool IncludePreferredWhenRenaming { get; init; } + public IReadOnlyCollection Required { get; init; } = Array.Empty(); + public IReadOnlyCollection Ignored { get; init; } = Array.Empty(); + public IReadOnlyCollection Preferred { get; init; } = Array.Empty(); + + public override string ToString() + { + return $"[TrashId: {TrashId}] " + + $"[Name: {Name}] " + + $"[IncludePreferred: {IncludePreferredWhenRenaming}] " + + $"[Required: {Required.Count}] " + + $"[Ignored: {Ignored.Count}] " + + $"[Preferred: {Preferred.Count}]"; + } +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataFilterer.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataFilterer.cs new file mode 100644 index 00000000..549139ac --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataFilterer.cs @@ -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 failures, string filterDescription) + { + _log.Debug("Validation failed on term data ({Filter}): {Failures}", filterDescription, failures); + } + + public ReadOnlyCollection ExcludeTerms(IEnumerable terms, + IEnumerable includeFilter) + { + return terms + .ExceptBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase) + .IsValid(new TermDataValidator(), (e, x) => LogInvalidTerm(e, $"Exclude: {x}")) + .ToList().AsReadOnly(); + } + + public ReadOnlyCollection ExcludeTerms(IEnumerable terms, + IReadOnlyCollection 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 IncludeTerms(IEnumerable terms, + IEnumerable includeFilter) + { + return terms + .IntersectBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase) + .IsValid(new TermDataValidator(), + (e, x) => LogInvalidTerm(e, $"Include: {x}")) + .ToList().AsReadOnly(); + } + + public ReadOnlyCollection IncludeTerms(IEnumerable terms, + IReadOnlyCollection 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; + } +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataValidator.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataValidator.cs new file mode 100644 index 00000000..16b9cbd2 --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; + +namespace TrashLib.Sonarr.ReleaseProfile; + +internal class TermDataValidator : AbstractValidator +{ + public TermDataValidator() + { + RuleFor(x => x.Term).NotEmpty(); + } +} + +internal class PreferredTermDataValidator : AbstractValidator +{ + public PreferredTermDataValidator() + { + RuleFor(x => x.Terms).NotEmpty(); + } +} + +internal class ReleaseProfileDataValidator : AbstractValidator +{ + 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"); + } +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs deleted file mode 100644 index ac9db87e..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs +++ /dev/null @@ -1,328 +0,0 @@ -using System.Text.RegularExpressions; -using Common.Extensions; -using Flurl; -using Flurl.Http; -using Serilog; -using TrashLib.Sonarr.Config; - -namespace TrashLib.Sonarr.ReleaseProfile; - -internal class ReleaseProfileGuideParser : IReleaseProfileGuideParser -{ - private readonly Dictionary _markdownDocNames = new() - { - {ReleaseProfileType.Anime, "Sonarr-Release-Profile-RegEx-Anime"}, - {ReleaseProfileType.Series, "Sonarr-Release-Profile-RegEx"} - }; - - private readonly (TermCategory, Regex)[] _regexCategories = - { - (TermCategory.Required, BuildRegex(@"must contain")), - (TermCategory.Ignored, BuildRegex(@"must not contain")), - (TermCategory.Preferred, BuildRegex(@"preferred")) - }; - - private readonly Regex _regexHeader = new(@"^(#+)\s(.+?)\s*$", RegexOptions.Compiled); - private readonly Regex _regexHeaderReleaseProfile = BuildRegex(@"release profile"); - private readonly Regex _regexPotentialScore = BuildRegex(@"\[(-?[\d]+)\]"); - private readonly Regex _regexScore = BuildRegex(@"score.*?\[(-?[\d]+)\]"); - - public ReleaseProfileGuideParser(ILogger logger) - { - Log = logger; - } - - private ILogger Log { get; } - - public async Task GetMarkdownData(ReleaseProfileType profileName) - { - return await BuildUrl(profileName).GetStringAsync(); - } - - public IDictionary ParseMarkdown(ReleaseProfileConfig config, string markdown) - { - var state = new ParserState(Log); - - var reader = new StringReader(markdown); - for (var line = reader.ReadLine(); line != null; line = reader.ReadLine()) - { - state.LineNumber++; - if (string.IsNullOrEmpty(line)) - { - continue; - } - - // Always check if we're starting a fenced code block. Whether we are inside one or not greatly affects - // the logic we use. - if (line.StartsWith("```")) - { - state.InsideCodeBlock = !state.InsideCodeBlock; - continue; - } - - // Not inside brackets - if (!state.InsideCodeBlock) - { - OutsideFence_ParseMarkdown(line, state); - } - // Inside brackets - else - { - if (!state.IsValid) - { - Log.Debug(" - !! Inside bracket with invalid state; skipping! " + - "[Profile Name: {ProfileName}] " + - "[Category: {Category}] " + "[Score: {Score}] " + "[Line: {Line}] ", - state.ProfileName, - state.CurrentCategory.Value, state.Score, line); - } - else - { - InsideFence_ParseMarkdown(config, line, state); - } - } - } - - Log.Debug("\n"); - return state.Results; - } - - private bool IsSkippableLine(string line) - { - // Skip lines with leading whitespace (i.e. indentation). - // These lines will almost always be `!!! attention` blocks of some kind and won't contain useful data. - if (char.IsWhiteSpace(line, 0)) - { - Log.Debug(" - Skip Indented Line: {Line}", line); - return true; - } - - // Lines that begin with `???` or `!!!` are admonition syntax (extended markdown supported by Python) - if (line.StartsWith("!!!") || line.StartsWith("???")) - { - Log.Debug(" - Skip Admonition: {Line}", line); - return true; - } - - return false; - } - - private static Regex BuildRegex(string regex) - { - return new Regex(regex, RegexOptions.Compiled | RegexOptions.IgnoreCase); - } - - private Url BuildUrl(ReleaseProfileType profileName) - { - return "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr".AppendPathSegment( - $"{_markdownDocNames[profileName]}.md"); - } - - private void InsideFence_ParseMarkdown(ReleaseProfileConfig config, string line, ParserState state) - { - // Sometimes a comma is present at the end of these lines, because when it's - // pasted into Sonarr it acts as a delimiter. However, when using them with the - // API we do not need them. - line = line.TrimEnd(','); - - var category = state.CurrentCategory.Value; - switch (category!.Value) - { - case TermCategory.Preferred: - { - Log.Debug(" + Capture Term " + - "[Category: {CurrentCategory}] " + - "[Optional: {Optional}] " + - "[Score: {Score}] " + - "[Strict: {StrictNegativeScores}] " + - "[Term: {Line}]", - category.Value, state.TermsAreOptional.Value, state.Score, config.StrictNegativeScores, line); - - if (config.StrictNegativeScores && state.Score < 0) - { - state.IgnoredTerms.Add(line); - } - else - { - // Score is already checked for null prior to the method being invoked. - var prefList = state.PreferredTerms.GetOrCreate(state.Score!.Value); - prefList.Add(line); - } - - break; - } - - case TermCategory.Ignored: - { - state.IgnoredTerms.Add(line); - Log.Debug(" + Capture Term " + - "[Category: {Category}] " + - "[Optional: {Optional}] " + - "[Term: {Line}]", - category.Value, state.TermsAreOptional.Value, line); - break; - } - - case TermCategory.Required: - { - state.RequiredTerms.Add(line); - Log.Debug(" + Capture Term " + - "[Category: {Category}] " + - "[Optional: {Optional}] " + - "[Term: {Line}]", - category.Value, state.TermsAreOptional.Value, line); - break; - } - - default: - { - throw new ArgumentOutOfRangeException($"Unknown term category: {category.Value}"); - } - } - } - - private void OutsideFence_ParseMarkdown(string line, ParserState state) - { - // ReSharper disable once InlineOutVariableDeclaration - Match match; - - // Header Processing. Never do any additional processing to headers, so return after processing it - if (_regexHeader.Match(line, out match)) - { - OutsideFence_ParseHeader(state, match); - return; - } - - // Until we find a header that defines a profile, we don't care about anything under it. - if (string.IsNullOrEmpty(state.ProfileName)) - { - return; - } - - // These are often found in admonition (indented) blocks, so we check for it before we - // run the IsSkippableLine() check. - if (line.ContainsIgnoreCase("include preferred")) - { - state.GetProfile().IncludePreferredWhenRenaming = !line.ContainsIgnoreCase("not"); - Log.Debug(" - 'Include Preferred' found [Value: {IncludePreferredWhenRenaming}] [Line: {Line}]", - state.GetProfile().IncludePreferredWhenRenaming, line); - return; - } - - if (IsSkippableLine(line)) - { - return; - } - - OutsideFence_ParseInformationOnSameLine(line, state); - } - - private void OutsideFence_ParseHeader(ParserState state, Match match) - { - var headerDepth = match.Groups[1].Length; - var headerText = match.Groups[2].Value; - state.CurrentHeaderDepth = headerDepth; - - // Always reset the scope-based state any time we see a header, regardless of depth or phrasing. - // Each header "resets" scope-based state, even if it's entering into a nested header, which usually will - // not reset as much state. - state.ResetScopeState(headerDepth); - - Log.Debug("> Parsing Header [Nested: {Nested}] [Depth: {HeaderDepth}] [Text: {HeaderText}]", - headerDepth > state.ProfileHeaderDepth, headerDepth, headerText); - - // Profile name (always reset previous state here) - if (_regexHeaderReleaseProfile.Match(headerText).Success) - { - state.ResetParserState(); - state.ProfileName = headerText; - state.ProfileHeaderDepth = headerDepth; - Log.Debug(" - New Profile [Text: {HeaderText}]", headerText); - } - else if (headerDepth <= state.ProfileHeaderDepth) - { - Log.Debug(" - !! Non-nested, non-profile header found; resetting all state"); - state.ResetParserState(); - } - - // If a single header can be parsed with multiple phrases, add more if conditions below this comment. - // In order to make sure all checks happen as needed, do not return from the condition (to allow conditions - // below it to be executed) - - // Another note: Any "state" set by headers has longer lasting effects. That state will remain in effect - // until the next header. That means multiple fenced code blocks will be impacted. - - ParseAndSetOptional(headerText, state); - ParseAndSetCategory(headerText, state); - } - - private void OutsideFence_ParseInformationOnSameLine(string line, ParserState state) - { - // ReSharper disable once InlineOutVariableDeclaration - Match match; - - ParseAndSetOptional(line, state); - ParseAndSetCategory(line, state); - - if (_regexScore.Match(line, out match)) - { - // As a convenience, if we find a score, we obviously should set the category to Preferred even if - // the guide didn't explicitly mention that. - state.CurrentCategory.PushValue(TermCategory.Preferred, state.CurrentHeaderDepth); - - state.Score = int.Parse(match.Groups[1].Value); - Log.Debug(" - Score [Value: {Score}]", state.Score); - } - else if (_regexPotentialScore.Match(line, out match)) - { - Log.Warning("Found a potential score on line #{Line} that will be ignored because the " + - "word 'score' is missing (This is probably a bug in the guide itself): {ScoreMatch}", - state.LineNumber, match.Groups[0].Value); - } - } - - private void ParseAndSetCategory(string line, ParserState state) - { - var category = ParseCategory(line); - if (category == null) - { - return; - } - - state.CurrentCategory.PushValue(category.Value, state.CurrentHeaderDepth); - - Log.Debug(" - Category Set " + - "[Scope: {Scope}] " + - "[Name: {Category}] " + - "[Stack Size: {StackSize}] " + - "[Line: {Line}]", - category.Value, state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line); - } - - private void ParseAndSetOptional(string line, ParserState state) - { - if (line.ContainsIgnoreCase("optional")) - { - state.TermsAreOptional.PushValue(true, state.CurrentHeaderDepth); - - Log.Debug(" - Optional Set " + - "[Scope: {Scope}] " + - "[Stack Size: {StackSize}] " + - "[Line: {Line}]", - state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line); - } - } - - private TermCategory? ParseCategory(string line) - { - foreach (var (category, regex) in _regexCategories) - { - if (regex.Match(line).Success) - { - return category; - } - } - - return null; - } -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileType.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileType.cs deleted file mode 100644 index 99922a89..00000000 --- a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileType.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace TrashLib.Sonarr.ReleaseProfile; - -public enum ReleaseProfileType -{ - Anime, - Series -} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs index fc6f295a..b1a584fd 100644 --- a/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs +++ b/src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileUpdater.cs @@ -5,6 +5,7 @@ using TrashLib.ExceptionTypes; using TrashLib.Sonarr.Api; using TrashLib.Sonarr.Api.Objects; using TrashLib.Sonarr.Config; +using TrashLib.Sonarr.ReleaseProfile.Guide; namespace TrashLib.Sonarr.ReleaseProfile; @@ -12,44 +13,132 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater { private readonly ISonarrApi _api; private readonly ISonarrCompatibility _compatibility; - private readonly IReleaseProfileGuideParser _parser; + private readonly ISonarrGuideService _guide; + private readonly ILogger _log; public ReleaseProfileUpdater( ILogger logger, - IReleaseProfileGuideParser parser, + ISonarrGuideService guide, ISonarrApi api, ISonarrCompatibility compatibility) { - Log = logger; - _parser = parser; + _log = logger; + _guide = guide; _api = api; _compatibility = compatibility; } - private ILogger Log { get; } - public async Task Process(bool isPreview, SonarrConfiguration config) { - foreach (var profile in config.ReleaseProfiles) + var profilesFromGuide = _guide.GetReleaseProfileData(); + + var filteredProfiles = new List<(ReleaseProfileData Profile, IReadOnlyCollection Tags)>(); + var filterer = new ReleaseProfileDataFilterer(_log); + + var configProfiles = config.ReleaseProfiles.SelectMany(x => x.TrashIds.Select(y => (TrashId: y, Config: x))); + foreach (var (trashId, configProfile) in configProfiles) { - Log.Information("Processing Release Profile: {ProfileName}", profile.Type); - var markdown = await _parser.GetMarkdownData(profile.Type); - var profileData = _parser.ParseMarkdown(profile, markdown); - var profiles = Utils.FilterProfiles(profileData, profile.Filter); + // For each release profile specified in our YAML config, find the matching profile in the guide. + var selectedProfile = profilesFromGuide.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId)); + if (selectedProfile is null) + { + _log.Warning("A release profile with Trash ID {TrashId} does not exist", trashId); + continue; + } + + _log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name, + selectedProfile.TrashId); - if (profile.Filter.IncludeOptional) + if (configProfile.Filter != null) { - Log.Information("Configuration is set to allow optional terms to be synchronized"); + _log.Debug("This profile will be filtered"); + var newProfile = filterer.FilterProfile(selectedProfile, configProfile.Filter); + if (newProfile is not null) + { + selectedProfile = newProfile; + } } if (isPreview) { - Utils.PrintTermsAndScores(profiles); + Utils.PrintTermsAndScores(selectedProfile); continue; } - await ProcessReleaseProfiles(profiles, profile); + filteredProfiles.Add((selectedProfile, configProfile.Tags)); } + + await ProcessReleaseProfiles(filteredProfiles); + } + + private async Task ProcessReleaseProfiles( + List<(ReleaseProfileData Profile, IReadOnlyCollection Tags)> profilesAndTags) + { + await DoVersionEnforcement(); + + // Obtain all of the existing release profiles first. If any were previously created by our program + // here, we favor replacing those instead of creating new ones, which would just be mostly duplicates + // (but with some differences, since there have likely been updates since the last run). + var existingProfiles = await _api.GetReleaseProfiles(); + + foreach (var (profile, tags) in profilesAndTags) + { + // If tags were provided, ensure they exist. Tags that do not exist are added first, so that we + // may specify them with the release profile request payload. + var tagIds = await CreateTagsInSonarr(tags); + + var title = BuildProfileTitle(profile.Name); + var profileToUpdate = GetProfileToUpdate(existingProfiles, title); + if (profileToUpdate != null) + { + _log.Information("Update existing profile: {ProfileName}", title); + await UpdateExistingProfile(profileToUpdate, profile, tagIds); + } + else + { + _log.Information("Create new profile: {ProfileName}", title); + await CreateNewProfile(title, profile, tagIds); + } + } + + // Any profiles with `[Trash]` in front of their name are managed exclusively by Trash Updater. As such, if + // there are any still in Sonarr that we didn't update, those are most certainly old and shouldn't be kept + // around anymore. + await DeleteOldManagedProfiles(profilesAndTags, existingProfiles); + } + + private async Task DeleteOldManagedProfiles( + IEnumerable<(ReleaseProfileData Profile, IReadOnlyCollection Tags)> profilesAndTags, + IEnumerable sonarrProfiles) + { + var profiles = profilesAndTags.Select(x => x.Profile).ToList(); + var sonarrProfilesToDelete = sonarrProfiles + .Where(sonarrProfile => + { + return sonarrProfile.Name.StartsWithIgnoreCase("[Trash]") && + !profiles.Any(profile => sonarrProfile.Name.EndsWithIgnoreCase(profile.Name)); + }); + + foreach (var profile in sonarrProfilesToDelete) + { + _log.Information("Deleting old Trash release profile: {ProfileName}", profile.Name); + await _api.DeleteReleaseProfile(profile.Id); + } + } + + private async Task> CreateTagsInSonarr(IReadOnlyCollection tags) + { + if (!tags.Any()) + { + return Array.Empty(); + } + + var sonarrTags = await _api.GetTags(); + await CreateMissingTags(sonarrTags, tags); + return sonarrTags + .Where(t => tags.Any(ct => ct.EqualsIgnoreCase(t.Label))) + .Select(t => t.Id) + .ToList(); } private async Task DoVersionEnforcement() @@ -68,16 +157,17 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater var missingTags = configTags.Where(t => !sonarrTags.Any(t2 => t2.Label.EqualsIgnoreCase(t))); foreach (var tag in missingTags) { - Log.Debug("Creating Tag: {Tag}", tag); + _log.Debug("Creating Tag: {Tag}", tag); var newTag = await _api.CreateTag(tag); sonarrTags.Add(newTag); } } - private string BuildProfileTitle(ReleaseProfileType profileType, string profileName) + private const string ProfileNamePrefix = "[Trash]"; + + private static string BuildProfileTitle(string profileName) { - var titleType = profileType.ToString(); - return $"[Trash] {titleType} - {profileName}"; + return $"{ProfileNamePrefix} {profileName}"; } private static SonarrReleaseProfile? GetProfileToUpdate(IEnumerable profiles, @@ -86,83 +176,31 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater return profiles.FirstOrDefault(p => p.Name == profileName); } - private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, FilteredProfileData profile, - List tagIds) + private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, ReleaseProfileData profile, + IReadOnlyCollection tagIds) { profileToUpdate.Preferred = profile.Preferred - .SelectMany(kvp => kvp.Value.Select(term => new SonarrPreferredTerm(kvp.Key, term))) + .SelectMany(x => x.Terms.Select(termData => new SonarrPreferredTerm(x.Score, termData.Term))) .ToList(); - profileToUpdate.Ignored = profile.Ignored.ToList(); //string.Join(',', profile.Ignored); - profileToUpdate.Required = profile.Required.ToList(); //string.Join(',', profile.Required); - - // Null means the guide didn't specify a value for this, so we leave the existing setting intact. - if (profile.IncludePreferredWhenRenaming != null) - { - profileToUpdate.IncludePreferredWhenRenaming = profile.IncludePreferredWhenRenaming.Value; - } - + profileToUpdate.Ignored = profile.Ignored.Select(x => x.Term).ToList(); + profileToUpdate.Required = profile.Required.Select(x => x.Term).ToList(); + profileToUpdate.IncludePreferredWhenRenaming = profile.IncludePreferredWhenRenaming; profileToUpdate.Tags = tagIds; } - private async Task UpdateExistingProfile(SonarrReleaseProfile profileToUpdate, FilteredProfileData profile, - List tagIds) + private async Task UpdateExistingProfile(SonarrReleaseProfile profileToUpdate, ReleaseProfileData profile, + IReadOnlyCollection tagIds) { - Log.Debug("Update existing profile with id {ProfileId}", profileToUpdate.Id); + _log.Debug("Update existing profile with id {ProfileId}", profileToUpdate.Id); SetupProfileRequestObject(profileToUpdate, profile, tagIds); await _api.UpdateReleaseProfile(profileToUpdate); } - private async Task CreateNewProfile(string title, FilteredProfileData profile, List tagIds) + private async Task CreateNewProfile(string title, ReleaseProfileData profile, IReadOnlyCollection tagIds) { - var newProfile = new SonarrReleaseProfile - { - Name = title, - Enabled = true - }; - + var newProfile = new SonarrReleaseProfile {Name = title, Enabled = true}; SetupProfileRequestObject(newProfile, profile, tagIds); await _api.CreateReleaseProfile(newProfile); } - - private async Task ProcessReleaseProfiles(IDictionary profiles, - ReleaseProfileConfig config) - { - await DoVersionEnforcement(); - - List tagIds = new(); - - // If tags were provided, ensure they exist. Tags that do not exist are added first, so that we - // may specify them with the release profile request payload. - if (config.Tags.Count > 0) - { - var sonarrTags = await _api.GetTags(); - await CreateMissingTags(sonarrTags, config.Tags); - tagIds = sonarrTags.Where(t => config.Tags.Any(ct => ct.EqualsIgnoreCase(t.Label))) - .Select(t => t.Id) - .ToList(); - } - - // Obtain all of the existing release profiles first. If any were previously created by our program - // here, we favor replacing those instead of creating new ones, which would just be mostly duplicates - // (but with some differences, since there have likely been updates since the last run). - var existingProfiles = await _api.GetReleaseProfiles(); - - foreach (var (name, profileData) in profiles) - { - var filteredProfileData = new FilteredProfileData(profileData, config); - var title = BuildProfileTitle(config.Type, name); - var profileToUpdate = GetProfileToUpdate(existingProfiles, title); - if (profileToUpdate != null) - { - Log.Information("Update existing profile: {ProfileName}", title); - await UpdateExistingProfile(profileToUpdate, filteredProfileData, tagIds); - } - else - { - Log.Information("Create new profile: {ProfileName}", title); - await CreateNewProfile(title, filteredProfileData, tagIds); - } - } - } } diff --git a/src/TrashLib/Sonarr/ReleaseProfile/TermDataConverter.cs b/src/TrashLib/Sonarr/ReleaseProfile/TermDataConverter.cs new file mode 100644 index 00000000..b31f0439 --- /dev/null +++ b/src/TrashLib/Sonarr/ReleaseProfile/TermDataConverter.cs @@ -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(), + JTokenType.String => new TermData {Term = token.ToString()}, + _ => null + }; + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(TermData); + } +} diff --git a/src/TrashLib/Sonarr/ReleaseProfile/Utils.cs b/src/TrashLib/Sonarr/ReleaseProfile/Utils.cs index e60cf388..c9e64d21 100644 --- a/src/TrashLib/Sonarr/ReleaseProfile/Utils.cs +++ b/src/TrashLib/Sonarr/ReleaseProfile/Utils.cs @@ -1,56 +1,18 @@ -using TrashLib.Sonarr.Config; - -namespace TrashLib.Sonarr.ReleaseProfile; - -using ProfileDataCollection = IDictionary; +namespace TrashLib.Sonarr.ReleaseProfile; public static class Utils { - public static ProfileDataCollection FilterProfiles(ProfileDataCollection profiles, SonarrProfileFilterConfig filter) + public static void PrintTermsAndScores(ReleaseProfileData profile) { - bool IsEmpty(ProfileData data) + static void PrintPreferredTerms(string title, IReadOnlyCollection preferredTerms) { - var isEmpty = data is - { - // Non-optional - Required.Count: 0, - Ignored.Count: 0, - Preferred.Count: 0 - }; - - if (isEmpty && filter.IncludeOptional) - { - isEmpty = data is - { - // Optional - Optional.Required.Count: 0, - Optional.Ignored.Count: 0, - Optional.Preferred.Count: 0 - }; - } - - return isEmpty; - } - - // A few false-positive profiles are added sometimes. We filter these out by checking if they - // actually have meaningful data attached to them, such as preferred terms. If they are mostly empty, - // we remove them here. - return profiles - .Where(kv => !IsEmpty(kv.Value)) - .ToDictionary(kv => kv.Key, kv => kv.Value); - } - - public static void PrintTermsAndScores(ProfileDataCollection profiles) - { - static void PrintPreferredTerms(string title, IDictionary> dict) - { - if (dict.Count <= 0) + if (preferredTerms.Count <= 0) { return; } Console.WriteLine($" {title}:"); - foreach (var (score, terms) in dict) + foreach (var (score, terms) in preferredTerms) { foreach (var term in terms) { @@ -61,7 +23,7 @@ public static class Utils Console.WriteLine(""); } - static void PrintTerms(string title, ICollection terms) + static void PrintTerms(string title, IReadOnlyCollection terms) { if (terms.Count == 0) { @@ -79,26 +41,17 @@ public static class Utils Console.WriteLine(""); - foreach (var (name, profile) in profiles) - { - Console.WriteLine(name); + Console.WriteLine(profile.Name); - if (profile.IncludePreferredWhenRenaming != null) - { - Console.WriteLine(" Include Preferred when Renaming?"); - Console.WriteLine(" " + - (profile.IncludePreferredWhenRenaming.Value ? "CHECKED" : "NOT CHECKED")); - Console.WriteLine(""); - } + Console.WriteLine(" Include Preferred when Renaming?"); + Console.WriteLine(" " + + (profile.IncludePreferredWhenRenaming ? "YES" : "NO")); + Console.WriteLine(""); - PrintTerms("Must Contain", profile.Required); - PrintTerms("Must Contain (Optional)", profile.Optional.Required); - PrintTerms("Must Not Contain", profile.Ignored); - PrintTerms("Must Not Contain (Optional)", profile.Optional.Ignored); - PrintPreferredTerms("Preferred", profile.Preferred); - PrintPreferredTerms("Preferred (Optional)", profile.Optional.Preferred); + PrintTerms("Must Contain", profile.Required); + PrintTerms("Must Not Contain", profile.Ignored); + PrintPreferredTerms("Preferred", profile.Preferred); - Console.WriteLine(""); - } + Console.WriteLine(""); } } diff --git a/src/TrashLib/Sonarr/SonarrAutofacModule.cs b/src/TrashLib/Sonarr/SonarrAutofacModule.cs index 8367656a..671f2592 100644 --- a/src/TrashLib/Sonarr/SonarrAutofacModule.cs +++ b/src/TrashLib/Sonarr/SonarrAutofacModule.cs @@ -3,6 +3,7 @@ using TrashLib.Sonarr.Api; using TrashLib.Sonarr.Config; using TrashLib.Sonarr.QualityDefinition; using TrashLib.Sonarr.ReleaseProfile; +using TrashLib.Sonarr.ReleaseProfile.Guide; namespace TrashLib.Sonarr; @@ -19,7 +20,7 @@ public class SonarrAutofacModule : Module // Release Profile Support builder.RegisterType().As(); - builder.RegisterType().As(); + builder.RegisterType().As(); builder.RegisterType() .As(); diff --git a/src/VersionControl/GitRepositoryFactory.cs b/src/VersionControl/GitRepositoryFactory.cs index 714b007f..cb567c7e 100644 --- a/src/VersionControl/GitRepositoryFactory.cs +++ b/src/VersionControl/GitRepositoryFactory.cs @@ -36,7 +36,7 @@ public class GitRepositoryFactory : IGitRepositoryFactory var progress = new ProgressBar { - Description = "Requesting and parsing guide markdown" + Description = "Fetching guide data" }; _staticWrapper.Clone(repoUrl, repoPath, new CloneOptions