From 434158f7a611de4d5c1ee236b3218843fee1a0c5 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 10 Apr 2022 10:29:42 -0500 Subject: [PATCH] feat(sonarr)!: Parse Guide Release Profile JSON Previously, Trash Updater would crawl & parse the Trash Guide's markdown files to obtain information about release profiles. This is complex and error prone. Thanks to work done by Nitsua, we now have JSON files available that describe release profiles in a more concise way. These files are located at `docs/json/sonarr` in the [Trash Guide repo][1]. All of the markdown parsing code has been removed from Trash Updater. Now, it shares the same git clone of the Trash Guide repository originally used for Radarr custom formats to access those release profile JSON files. BREAKING CHANGE: The old `type:` property for release profiles is removed in favor of `trash_id:`, which identifies a specific JSON file to pull data from. Users are required to update their `trash.yml` and other configuration files to use the new schema. Until changes are made, users will see errors when they run `trash sonarr` commands. [1]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarr --- CHANGELOG.md | 18 +- .../Extensions/FluentValidationExtensions.cs | 39 -- .../{ => Extensions}/JsonNetExtensions.cs | 2 +- src/Common/Extensions/StringExtensions.cs | 18 +- .../FluentValidationExtensions.cs | 38 ++ .../NullableChildValidatorAdaptor.cs | 24 + .../ReadOnlyCollectionNodeTypeResolver.cs | 29 ++ .../Services/ConfigurationLoaderTest.cs | 5 +- .../Data/Load_UsingStream_CorrectParsing.yml | 4 +- src/Trash/Command/RadarrCommand.cs | 6 +- src/Trash/Command/ServiceCommand.cs | 63 ++- src/Trash/Command/SonarrCommand.cs | 6 +- src/Trash/trash-config-template.yml | 13 +- .../LocalRepoCustomFormatJsonParserTest.cs | 30 ++ .../ReleaseProfile/FilteredProfileDataTest.cs | 90 ---- .../LocalRepoReleaseProfileJsonParserTest.cs | 52 +++ .../ReleaseProfileDataFiltererTest.cs | 194 ++++++++ .../ReleaseProfileDataValidatorTest.cs | 98 ++++ .../ReleaseProfileParserTest.cs | 421 ------------------ .../Sonarr/ReleaseProfile/UtilsTest.cs | 148 ------ .../Sonarr/ReleaseProfileUpdaterTest.cs | 50 --- .../Sonarr/SonarrConfigurationTest.cs | 21 +- src/TrashLib/Config/YamlSerializerFactory.cs | 1 + src/TrashLib/Extensions/FlurlLogging.cs | 4 +- .../Config/RadarrConfigurationValidator.cs | 2 +- .../Guide/LocalRepoCustomFormatJsonParser.cs | 16 +- .../Processors/GuideSteps/CustomFormatStep.cs | 1 - .../QualityProfileApiPersistenceStep.cs | 1 - src/TrashLib/Sonarr/Api/ISonarrApi.cs | 1 + src/TrashLib/Sonarr/Api/SonarrApi.cs | 7 + .../Config/ISonarrValidationMessages.cs | 2 +- .../Sonarr/Config/SonarrConfiguration.cs | 17 +- .../Config/SonarrConfigurationValidator.cs | 2 +- .../Sonarr/Config/SonarrValidationMessages.cs | 4 +- .../ReleaseProfile/FilteredProfileData.cs | 32 -- .../Guide/ISonarrGuideService.cs | 6 + .../LocalRepoReleaseProfileJsonParser.cs | 36 ++ .../IReleaseProfileGuideParser.cs | 9 - .../Sonarr/ReleaseProfile/ParserState.cs | 77 ---- .../Sonarr/ReleaseProfile/ProfileData.cs | 23 - .../ReleaseProfile/ReleaseProfileData.cs | 60 +++ .../ReleaseProfileDataFilterer.cs | 104 +++++ .../ReleaseProfileDataValidator.cs | 31 ++ .../ReleaseProfileGuideParser.cs | 328 -------------- .../ReleaseProfile/ReleaseProfileType.cs | 7 - .../ReleaseProfile/ReleaseProfileUpdater.cs | 202 +++++---- .../ReleaseProfile/TermDataConverter.cs | 29 ++ src/TrashLib/Sonarr/ReleaseProfile/Utils.cs | 77 +--- src/TrashLib/Sonarr/SonarrAutofacModule.cs | 3 +- src/VersionControl/GitRepositoryFactory.cs | 2 +- 50 files changed, 988 insertions(+), 1465 deletions(-) delete mode 100644 src/Common/Extensions/FluentValidationExtensions.cs rename src/Common/{ => Extensions}/JsonNetExtensions.cs (94%) create mode 100644 src/Common/FluentValidation/FluentValidationExtensions.cs create mode 100644 src/Common/FluentValidation/NullableChildValidatorAdaptor.cs create mode 100644 src/Common/YamlDotNet/ReadOnlyCollectionNodeTypeResolver.cs create mode 100644 src/TrashLib.Tests/Radarr/CustomFormat/Guide/LocalRepoCustomFormatJsonParserTest.cs delete mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/FilteredProfileDataTest.cs create mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParserTest.cs create mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataFiltererTest.cs create mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileDataValidatorTest.cs delete mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs delete mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfile/UtilsTest.cs delete mode 100644 src/TrashLib.Tests/Sonarr/ReleaseProfileUpdaterTest.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/FilteredProfileData.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/Guide/ISonarrGuideService.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/Guide/LocalRepoReleaseProfileJsonParser.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/IReleaseProfileGuideParser.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ParserState.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ProfileData.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileData.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataFilterer.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileDataValidator.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs delete mode 100644 src/TrashLib/Sonarr/ReleaseProfile/ReleaseProfileType.cs create mode 100644 src/TrashLib/Sonarr/ReleaseProfile/TermDataConverter.cs 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