From c21fc51b237fbb1d4d00462145d313cc58fc74e6 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Thu, 6 May 2021 18:25:49 -0500 Subject: [PATCH] feat(radarr): custom format support - Synchronize custom formats to Radarr - Quality profiles can be assigned scores from the guide - Deletion support for custom formats removed from config or the guide. - Caching system for keeping track of Custom Format IDs and Trash IDs to better support renames, deletions, and other stateful behavior. --- CHANGELOG.md | 4 + src/.editorconfig | 6 +- src/Directory.Build.props | 3 + src/Directory.Build.targets | 6 +- .../FluentAssertions/JsonEquivalencyStep.cs | 22 ++ src/TestLibrary/NSubstitute/Verify.cs | 41 ++ src/TestLibrary/TestLibrary.csproj | 3 + src/Trash.Tests/Cache/ServiceCacheTest.cs | 6 +- .../Config/ConfigurationLoaderTest.cs | 2 +- .../Config/ServiceConfigurationTest.cs | 14 +- .../CreateConfig/CreateConfigCommandTest.cs | 4 +- .../Radarr/CustomFormat/CachePersisterTest.cs | 122 ++++++ .../CustomFormat/CustomFormatUpdaterTest.cs | 67 ++++ .../Processors/Data/CF_Markdown1.md | 71 ++++ .../Data/ImportableCustomFormat1.json | 14 + .../ImportableCustomFormat1_Processed.json | 13 + .../Data/ImportableCustomFormat2.json | 14 + .../ImportableCustomFormat2_Processed.json | 13 + .../Processors/GuideProcessorTest.cs | 163 ++++++++ .../Processors/GuideSteps/ConfigStepTest.cs | 211 ++++++++++ .../GuideSteps/CustomFormatStepTest.cs | 317 +++++++++++++++ .../GuideSteps/QualityProfileStepTest.cs | 137 +++++++ .../Processors/PersistenceProcessorTest.cs | 56 +++ .../CustomFormatApiPersistenceStepTest.cs | 47 +++ .../JsonTransactionStepTest.cs | 366 ++++++++++++++++++ .../QualityProfileApiPersistenceStepTest.cs | 165 ++++++++ .../Radarr/RadarrConfigurationTest.cs | 45 ++- src/Trash.Tests/Trash.Tests.csproj | 4 + src/Trash/AppPaths.cs | 4 +- src/Trash/Cache/ServiceCache.cs | 2 +- src/Trash/CompositionRoot.cs | 33 +- src/Trash/Extensions/LinqExtensions.cs | 57 +++ src/Trash/Extensions/StringExtensions.cs | 2 +- .../CustomFormat/Api/CustomFormatService.cs | 64 +++ .../CustomFormat/Api/ICustomFormatService.cs | 15 + .../Api/IQualityProfileService.cs | 12 + .../CustomFormat/Api/QualityProfileService.cs | 36 ++ .../Radarr/CustomFormat/ApiOperationType.cs | 10 + .../Radarr/CustomFormat/CachePersister.cs | 58 +++ .../CustomFormat/CustomFormatUpdater.cs | 241 ++++++++++++ .../CustomFormat/Guide/CustomFormatData.cs | 8 + .../Guide/CustomFormatGuideParser.cs | 149 +++++++ .../Guide/ICustomFormatGuideParser.cs | 11 + .../Radarr/CustomFormat/Guide/ParserState.cs | 26 ++ .../Radarr/CustomFormat/ICachePersister.cs | 14 + .../CustomFormat/ICustomFormatUpdater.cs | 10 + .../Models/Cache/CustomFormatMapping.cs | 25 ++ .../Models/ProcessedConfigData.cs | 10 + .../Models/ProcessedCustomFormatData.cs | 36 ++ .../QualityProfileCustomFormatScoreEntry.cs | 14 + .../CustomFormat/Processors/GuideProcessor.cs | 91 +++++ .../Processors/GuideSteps/ConfigStep.cs | 67 ++++ .../Processors/GuideSteps/CustomFormatStep.cs | 101 +++++ .../Processors/GuideSteps/IConfigStep.cs | 15 + .../GuideSteps/ICustomFormatStep.cs | 17 + .../GuideSteps/IQualityProfileStep.cs | 12 + .../GuideSteps/QualityProfileStep.cs | 43 ++ .../Processors/IGuideProcessor.cs | 21 + .../Processors/IPersistenceProcessor.cs | 21 + .../Processors/PersistenceProcessor.cs | 80 ++++ .../CustomFormatApiPersistenceStep.cs | 26 ++ .../ICustomFormatApiPersistenceStep.cs | 10 + .../PersistenceSteps/IJsonTransactionStep.cs | 17 + .../IQualityProfileApiPersistenceStep.cs | 16 + .../PersistenceSteps/JsonTransactionStep.cs | 164 ++++++++ .../QualityProfileApiPersistenceStep.cs | 70 ++++ .../Api/IQualityDefinitionService.cs} | 6 +- .../Objects/RadarrQualityDefinitionItem.cs | 2 +- .../Api/QualityDefinitionService.cs} | 19 +- .../RadarrQualityDefinitionUpdater.cs | 8 +- src/Trash/Radarr/RadarrCommand.cs | 13 +- src/Trash/Radarr/RadarrConfiguration.cs | 24 +- src/Trash/Sonarr/Api/ISonarrApi.cs | 4 +- .../Sonarr/ReleaseProfile/ParserState.cs | 1 - src/Trash/Sonarr/SonarrCommand.cs | 2 +- src/Trash/Trash.csproj | 16 +- .../YamlDotNet/CannotBeEmptyAttribute.cs | 17 + src/Trash/trash-config-template.yml | 23 ++ src/TrashUpdater.sln.DotSettings | 4 + 79 files changed, 3626 insertions(+), 57 deletions(-) create mode 100644 src/TestLibrary/FluentAssertions/JsonEquivalencyStep.cs create mode 100644 src/TestLibrary/NSubstitute/Verify.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/CachePersisterTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/CustomFormatUpdaterTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/Data/CF_Markdown1.md create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1.json create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1_Processed.json create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2.json create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2_Processed.json create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStepTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStepTest.cs create mode 100644 src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs create mode 100644 src/Trash/Extensions/LinqExtensions.cs create mode 100644 src/Trash/Radarr/CustomFormat/Api/CustomFormatService.cs create mode 100644 src/Trash/Radarr/CustomFormat/Api/ICustomFormatService.cs create mode 100644 src/Trash/Radarr/CustomFormat/Api/IQualityProfileService.cs create mode 100644 src/Trash/Radarr/CustomFormat/Api/QualityProfileService.cs create mode 100644 src/Trash/Radarr/CustomFormat/ApiOperationType.cs create mode 100644 src/Trash/Radarr/CustomFormat/CachePersister.cs create mode 100644 src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs create mode 100644 src/Trash/Radarr/CustomFormat/Guide/CustomFormatData.cs create mode 100644 src/Trash/Radarr/CustomFormat/Guide/CustomFormatGuideParser.cs create mode 100644 src/Trash/Radarr/CustomFormat/Guide/ICustomFormatGuideParser.cs create mode 100644 src/Trash/Radarr/CustomFormat/Guide/ParserState.cs create mode 100644 src/Trash/Radarr/CustomFormat/ICachePersister.cs create mode 100644 src/Trash/Radarr/CustomFormat/ICustomFormatUpdater.cs create mode 100644 src/Trash/Radarr/CustomFormat/Models/Cache/CustomFormatMapping.cs create mode 100644 src/Trash/Radarr/CustomFormat/Models/ProcessedConfigData.cs create mode 100644 src/Trash/Radarr/CustomFormat/Models/ProcessedCustomFormatData.cs create mode 100644 src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IConfigStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/ICustomFormatApiPersistenceStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IJsonTransactionStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStep.cs create mode 100644 src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs rename src/Trash/Radarr/{Api/IRadarrApi.cs => QualityDefinition/Api/IQualityDefinitionService.cs} (66%) rename src/Trash/Radarr/{ => QualityDefinition}/Api/Objects/RadarrQualityDefinitionItem.cs (93%) rename src/Trash/Radarr/{Api/RadarrApi.cs => QualityDefinition/Api/QualityDefinitionService.cs} (69%) create mode 100644 src/Trash/YamlDotNet/CannotBeEmptyAttribute.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1b8a42..44d8ab60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Radarr Custom Format Support. + ## [1.3.3] - 2021-05-06 ### Fixed diff --git a/src/.editorconfig b/src/.editorconfig index 15f6c1de..bac8b5a3 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -66,7 +66,7 @@ dotnet_diagnostic.bc42356.severity = warning dotnet_diagnostic.bc42358.severity = warning dotnet_diagnostic.ca1000.severity = none dotnet_diagnostic.ca1001.severity = none -dotnet_diagnostic.ca1002.severity = warning +dotnet_diagnostic.ca1002.severity = suggestion dotnet_diagnostic.ca1003.severity = warning dotnet_diagnostic.ca1005.severity = warning dotnet_diagnostic.ca1008.severity = warning @@ -77,7 +77,7 @@ dotnet_diagnostic.ca1016.severity = suggestion dotnet_diagnostic.ca1017.severity = warning dotnet_diagnostic.ca1018.severity = suggestion dotnet_diagnostic.ca1019.severity = warning -dotnet_diagnostic.ca1021.severity = warning +dotnet_diagnostic.ca1021.severity = suggestion dotnet_diagnostic.ca1024.severity = warning dotnet_diagnostic.ca1027.severity = warning dotnet_diagnostic.ca1028.severity = warning @@ -116,7 +116,7 @@ dotnet_diagnostic.ca1200.severity = none dotnet_diagnostic.ca1303.severity = none dotnet_diagnostic.ca1304.severity = none dotnet_diagnostic.ca1305.severity = none -dotnet_diagnostic.ca1307.severity = warning +dotnet_diagnostic.ca1307.severity = none dotnet_diagnostic.ca1308.severity = warning dotnet_diagnostic.ca1309.severity = none dotnet_diagnostic.ca1310.severity = none diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fee94f68..14d51333 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -23,6 +23,7 @@ + @@ -32,10 +33,12 @@ + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 60a466e4..10f4dc42 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -2,6 +2,7 @@ + @@ -10,10 +11,13 @@ + + + @@ -29,8 +33,8 @@ + - diff --git a/src/TestLibrary/FluentAssertions/JsonEquivalencyStep.cs b/src/TestLibrary/FluentAssertions/JsonEquivalencyStep.cs new file mode 100644 index 00000000..fa02d044 --- /dev/null +++ b/src/TestLibrary/FluentAssertions/JsonEquivalencyStep.cs @@ -0,0 +1,22 @@ +using FluentAssertions.Equivalency; +using FluentAssertions.Json; +using Newtonsoft.Json.Linq; + +namespace TestLibrary.FluentAssertions +{ + public class JsonEquivalencyStep : IEquivalencyStep + { + public bool CanHandle(IEquivalencyValidationContext context, IEquivalencyAssertionOptions config) + { + return context.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false; + } + + public bool Handle(IEquivalencyValidationContext context, IEquivalencyValidator parent, + IEquivalencyAssertionOptions config) + { + ((JToken) context.Subject).Should() + .BeEquivalentTo((JToken) context.Expectation, context.Because, context.BecauseArgs); + return true; + } + } +} diff --git a/src/TestLibrary/NSubstitute/Verify.cs b/src/TestLibrary/NSubstitute/Verify.cs new file mode 100644 index 00000000..3f00a12c --- /dev/null +++ b/src/TestLibrary/NSubstitute/Verify.cs @@ -0,0 +1,41 @@ +using System; +using System.Diagnostics; +using System.Linq; +using FluentAssertions.Execution; +using NSubstitute.Core.Arguments; + +namespace TestLibrary.NSubstitute +{ + public static class Verify + { + public static T That(Action action) + { + return ArgumentMatcher.Enqueue(new AssertionMatcher(action)); + } + + private class AssertionMatcher : IArgumentMatcher + { + private readonly Action _assertion; + + public AssertionMatcher(Action assertion) + { + _assertion = assertion; + } + + public bool IsSatisfiedBy(T argument) + { + using var scope = new AssertionScope(); + _assertion(argument); + + var failures = scope.Discard().ToList(); + if (failures.Count == 0) + { + return true; + } + + failures.ForEach(x => Trace.WriteLine(x)); + return false; + } + } + } +} diff --git a/src/TestLibrary/TestLibrary.csproj b/src/TestLibrary/TestLibrary.csproj index 5bf0c226..ac7987c6 100644 --- a/src/TestLibrary/TestLibrary.csproj +++ b/src/TestLibrary/TestLibrary.csproj @@ -3,6 +3,9 @@ + + + diff --git a/src/Trash.Tests/Cache/ServiceCacheTest.cs b/src/Trash.Tests/Cache/ServiceCacheTest.cs index 464fd604..b992102e 100644 --- a/src/Trash.Tests/Cache/ServiceCacheTest.cs +++ b/src/Trash.Tests/Cache/ServiceCacheTest.cs @@ -73,7 +73,7 @@ namespace Trash.Tests.Cache obj.Should().NotBeNull(); obj!.TestValue.Should().Be("Foo"); - ctx.Filesystem.File.Received().ReadAllText(Path.Join("testpath", "c59d1c81", $"{ValidObjectName}.json")); + ctx.Filesystem.File.Received().ReadAllText(Path.Combine("testpath", "c59d1c81", $"{ValidObjectName}.json")); } [Test] @@ -109,11 +109,11 @@ namespace Trash.Tests.Cache ctx.Cache.Save(new ObjectWithAttribute {TestValue = "Foo"}); - var expectedParentDirectory = Path.Join("testpath", "c59d1c81"); + var expectedParentDirectory = Path.Combine("testpath", "c59d1c81"); ctx.Filesystem.Directory.Received().CreateDirectory(expectedParentDirectory); dynamic expectedJson = new {TestValue = "Foo"}; - var expectedPath = Path.Join(expectedParentDirectory, $"{ValidObjectName}.json"); + var expectedPath = Path.Combine(expectedParentDirectory, $"{ValidObjectName}.json"); ctx.Filesystem.File.Received() .WriteAllText(expectedPath, JsonConvert.SerializeObject(expectedJson, Formatting.Indented)); } diff --git a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs index e3919e31..17a7b95f 100644 --- a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs +++ b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.IO.Abstractions; using System.Linq; diff --git a/src/Trash.Tests/Config/ServiceConfigurationTest.cs b/src/Trash.Tests/Config/ServiceConfigurationTest.cs index fe043dc0..bc2955c9 100644 --- a/src/Trash.Tests/Config/ServiceConfigurationTest.cs +++ b/src/Trash.Tests/Config/ServiceConfigurationTest.cs @@ -17,7 +17,7 @@ namespace Trash.Tests.Config { // This test class must be public otherwise it cannot be deserialized by YamlDotNet [UsedImplicitly] - public class TestServiceConfiguration : ServiceConfiguration + private class TestServiceConfiguration : ServiceConfiguration { public const string ServiceName = "test_service"; @@ -28,11 +28,11 @@ namespace Trash.Tests.Config } [Test] - public void Deserialize_BaseUrlMissing_Throw() + public void Deserialize_ApiKeyMissing_Throw() { const string yaml = @" test_service: -- api_key: b +- base_url: a "; var loader = new ConfigurationLoader( Substitute.For(), @@ -42,15 +42,15 @@ test_service: Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName); act.Should().Throw() - .WithMessage("*Property 'base_url' is required"); + .WithMessage("*Property 'api_key' is required"); } [Test] - public void Deserialize_ApiKeyMissing_Throw() + public void Deserialize_BaseUrlMissing_Throw() { const string yaml = @" test_service: -- base_url: a +- api_key: b "; var loader = new ConfigurationLoader( Substitute.For(), @@ -60,7 +60,7 @@ test_service: Action act = () => loader.LoadFromStream(new StringReader(yaml), TestServiceConfiguration.ServiceName); act.Should().Throw() - .WithMessage("*Property 'api_key' is required"); + .WithMessage("*Property 'base_url' is required"); } } } diff --git a/src/Trash.Tests/CreateConfig/CreateConfigCommandTest.cs b/src/Trash.Tests/CreateConfig/CreateConfigCommandTest.cs index 1c94372b..f6dbb829 100644 --- a/src/Trash.Tests/CreateConfig/CreateConfigCommandTest.cs +++ b/src/Trash.Tests/CreateConfig/CreateConfigCommandTest.cs @@ -21,7 +21,7 @@ namespace Trash.Tests.CreateConfig var filesystem = Substitute.For(); var cmd = new CreateConfigCommand(logger, filesystem); - await cmd.ExecuteAsync(Substitute.For()); + await cmd.ExecuteAsync(Substitute.For()).ConfigureAwait(false); filesystem.File.Received().Exists(Arg.Is(s => s.EndsWith("trash.yml"))); filesystem.File.Received().WriteAllText(Arg.Is(s => s.EndsWith("trash.yml")), Arg.Any()); @@ -37,7 +37,7 @@ namespace Trash.Tests.CreateConfig Path = "some/other/path.yml" }; - await cmd.ExecuteAsync(Substitute.For()); + await cmd.ExecuteAsync(Substitute.For()).ConfigureAwait(false); filesystem.File.Received().Exists(Arg.Is("some/other/path.yml")); filesystem.File.Received().WriteAllText(Arg.Is("some/other/path.yml"), Arg.Any()); diff --git a/src/Trash.Tests/Radarr/CustomFormat/CachePersisterTest.cs b/src/Trash.Tests/Radarr/CustomFormat/CachePersisterTest.cs new file mode 100644 index 00000000..f57c4483 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/CachePersisterTest.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; +using Serilog; +using Trash.Cache; +using Trash.Radarr.CustomFormat; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Tests.Radarr.CustomFormat +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class CachePersisterTest + { + private class Context + { + public Context() + { + Log = Substitute.For(); + ServiceCache = Substitute.For(); + Persister = new CachePersister(Log, ServiceCache); + } + + public CachePersister Persister { get; } + public ILogger Log { get; } + public IServiceCache ServiceCache { get; } + } + + private ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId) + { + return new(cfName, trashId, new JObject()) + { + CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId} + }; + } + + [Test] + public void Cf_cache_is_valid_after_successful_load() + { + var ctx = new Context(); + var testCfObj = new CustomFormatCache(); + ctx.ServiceCache.Load().Returns(testCfObj); + + ctx.Persister.Load(); + ctx.Persister.CfCache.Should().BeSameAs(testCfObj); + } + + [Test] + public void Cf_cache_returns_null_if_not_loaded() + { + var ctx = new Context(); + ctx.Persister.Load(); + ctx.Persister.CfCache.Should().BeNull(); + } + + [Test] + public void Save_works_with_valid_cf_cache() + { + var ctx = new Context(); + var testCfObj = new CustomFormatCache(); + ctx.ServiceCache.Load().Returns(testCfObj); + + ctx.Persister.Load(); + ctx.Persister.Save(); + + ctx.ServiceCache.Received().Save(Arg.Is(testCfObj)); + } + + [Test] + public void Saving_without_loading_does_nothing() + { + var ctx = new Context(); + ctx.Persister.Save(); + ctx.ServiceCache.DidNotReceive().Save(Arg.Any()); + } + + [Test] + public void Updating_overwrites_previous_cf_cache_and_updates_cf_data() + { + var ctx = new Context(); + + // Load initial CfCache just to test that it gets replaced + var testCfObj = new CustomFormatCache + { + TrashIdMappings = new List {new("", "") {CustomFormatId = 5}} + }; + ctx.ServiceCache.Load().Returns(testCfObj); + ctx.Persister.Load(); + + // Update with new cached items + var results = new CustomFormatTransactionData(); + results.NewCustomFormats.Add(QuickMakeCf("cfname", "trashid", 10)); + + var customFormatData = new List + { + new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)} + }; + + ctx.Persister.Update(customFormatData); + ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache + { + TrashIdMappings = new List {customFormatData[0].CacheEntry!} + }); + + customFormatData.Should().ContainSingle() + .Which.CacheEntry.Should().BeEquivalentTo( + new TrashIdMapping("trashid", "cfname") {CustomFormatId = 10}); + } + + [Test] + public void Updating_sets_cf_cache_without_loading() + { + var ctx = new Context(); + ctx.Persister.Update(new List()); + ctx.Persister.CfCache.Should().NotBeNull(); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/CustomFormatUpdaterTest.cs b/src/Trash.Tests/Radarr/CustomFormat/CustomFormatUpdaterTest.cs new file mode 100644 index 00000000..57e2f6e0 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/CustomFormatUpdaterTest.cs @@ -0,0 +1,67 @@ +// using System; +// using System.Collections.Generic; +// using System.IO; +// using Common; +// using FluentAssertions; +// using NSubstitute; +// using NUnit.Framework; +// using Serilog; +// using Trash.Radarr; +// using Trash.Radarr.CustomFormat; +// using Trash.Radarr.CustomFormat.Guide; +// +// namespace Trash.Tests.Radarr.CustomFormat +// { +// [TestFixture] +// [Parallelizable(ParallelScope.All)] +// public class RadarrCustomFormatUpdaterTest +// { +// private class Context +// { +// public ResourceDataReader ResourceData { get; } = +// new(typeof(RadarrCustomFormatUpdaterTest), "Data"); +// } +// +// [Test] +// public void ParseMarkdown_Preview_CorrectBehavior() +// { +// var context = new Context(); +// +// var testJsonList = new List +// { +// context.ResourceData.ReadData("ImportableCustomFormat1.json"), +// context.ResourceData.ReadData("ImportableCustomFormat2.json") +// }; +// +// var logger = Substitute.For(); +// var guideParser = Substitute.For(); +// var updater = new CustomFormatUpdater(logger, guideParser); +// +// guideParser.ParseMarkdown(Arg.Any()).Returns(testJsonList); +// +// var args = Substitute.For(); +// args.Preview.Returns(true); +// var config = new RadarrConfiguration(); +// +// var output = new StringWriter(); +// Console.SetOut(output); +// +// updater.Process(args, config); +// +// var expectedOutput = new List +// { +// // language=regex +// @"Surround Sound\s+43bb5f09c79641e7a22e48d440bd8868", +// // language=regex +// @"DTS-HD/DTS:X\s+4eb3c272d48db8ab43c2c85283b69744" +// }; +// +// foreach (var expectedLine in expectedOutput) +// { +// output.ToString().Should().MatchRegex(expectedLine); +// } +// } +// } +// } + + diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/CF_Markdown1.md b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/CF_Markdown1.md new file mode 100644 index 00000000..63957a59 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/CF_Markdown1.md @@ -0,0 +1,71 @@ + +Score [480] + +??? example "json" + + ```json + { + "trash_id": "4eb3c272d48db8ab43c2c85283b69744", + "name": "DTS-HD/DTS:X", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts.?(hd|es|x(?!\\d))", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts.?(hd|es|x(?!\\d))" + } + }] + } + ``` + +[TOP](#index) + +------ + +### Surround Sound + +>If you prefer all kind of surround sounds + +!!! warning + + Don't use this Custom Format in combination with the `Audio Advanced` CF if you want to fine tune your audio formats or else it will add up the scores. + + +Score [500] + +??? example "json" + + ```json + { + "trash_id": "43bb5f09c79641e7a22e48d440bd8868", + "name": "Surround Sound", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)" + } + }] + } + ``` + + ```json + { + "trash_id": "abc", + "name": "No Score" + } + ``` + + ```json + { + "trash_id": "xyz", + "name": "One that won't be in config" + } + ``` + + diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1.json b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1.json new file mode 100644 index 00000000..ba54edce --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1.json @@ -0,0 +1,14 @@ +{ + "trash_id": "43bb5f09c79641e7a22e48d440bd8868", + "name": "Surround Sound", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)" + } + }] +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1_Processed.json b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1_Processed.json new file mode 100644 index 00000000..66986f91 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat1_Processed.json @@ -0,0 +1,13 @@ +{ + "name": "Surround Sound", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts\\-?(hd|x)|truehd|atmos|dd(\\+|p)(5|7)" + } + }] +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2.json b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2.json new file mode 100644 index 00000000..708e8155 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2.json @@ -0,0 +1,14 @@ +{ + "trash_id": "4eb3c272d48db8ab43c2c85283b69744", + "name": "DTS-HD/DTS:X", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts.?(hd|es|x(?!\\d))", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts.?(hd|es|x(?!\\d))" + } + }] +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2_Processed.json b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2_Processed.json new file mode 100644 index 00000000..1e583afb --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/Data/ImportableCustomFormat2_Processed.json @@ -0,0 +1,13 @@ +{ + "name": "DTS-HD/DTS:X", + "includeCustomFormatWhenRenaming": false, + "specifications": [{ + "name": "dts.?(hd|es|x(?!\\d))", + "implementation": "ReleaseTitleSpecification", + "negate": false, + "required": false, + "fields": { + "value": "dts.?(hd|es|x(?!\\d))" + } + }] +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs new file mode 100644 index 00000000..ce4f8fc9 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Common; +using FluentAssertions; +using Flurl.Http.Testing; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Serilog; +using TestLibrary.FluentAssertions; +using Trash.Radarr; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Processors; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class GuideProcessorTest + { + private class TestGuideProcessorSteps : IGuideProcessorSteps + { + public ICustomFormatStep CustomFormat { get; } = new CustomFormatStep(); + public IConfigStep Config { get; } = new ConfigStep(); + public IQualityProfileStep QualityProfile { get; } = new QualityProfileStep(); + } + + private class Context + { + public Context() + { + Logger = new LoggerConfiguration() + .WriteTo.TestCorrelator() + .WriteTo.NUnitOutput() + .MinimumLevel.Debug() + .CreateLogger(); + + Data = new ResourceDataReader(typeof(GuideProcessorTest), "Data"); + } + + public ILogger Logger { get; } + public ResourceDataReader Data { get; } + + public JObject ReadJson(string jsonFile) + { + var jsonData = Data.ReadData(jsonFile); + return JObject.Parse(jsonData); + } + } + + [Test] + [SuppressMessage("Maintainability", "CA1506", Justification = "Designed to be a high-level integration test")] + public void Guide_processor_behaves_as_expected_with_normal_markdown() + { + var ctx = new Context(); + var guideProcessor = + new GuideProcessor(ctx.Logger, new CustomFormatGuideParser(ctx.Logger), + () => new TestGuideProcessorSteps()); + + // simulate guide data + using var testHttp = new HttpTest(); + testHttp.RespondWith(ctx.Data.ReadData("CF_Markdown1.md")); + + // Simulate user config in YAML + var config = new List + { + new() + { + Names = new List {"Surround SOUND", "DTS-HD/DTS:X", "no score", "not in guide 1"}, + QualityProfiles = new List + { + new() {Name = "profile1"}, + new() {Name = "profile2", Score = -1234} + } + }, + new() + { + Names = new List {"no score", "not in guide 2"}, + QualityProfiles = new List + { + new() {Name = "profile3"}, + new() {Name = "profile4", Score = 5678} + } + } + }; + + guideProcessor.BuildGuideData(config, null); + + var expectedProcessedCustomFormatData = new List + { + new("Surround Sound", "43bb5f09c79641e7a22e48d440bd8868", ctx.ReadJson( + "ImportableCustomFormat1_Processed.json")) + { + Score = 500 + }, + new("DTS-HD/DTS:X", "4eb3c272d48db8ab43c2c85283b69744", ctx.ReadJson( + "ImportableCustomFormat2_Processed.json")) + { + Score = 480 + }, + new("No Score", "abc", JObject.FromObject(new {name = "No Score"})) + }; + + guideProcessor.ProcessedCustomFormats.Should().BeEquivalentTo(expectedProcessedCustomFormatData, + op => op.Using(new JsonEquivalencyStep())); + + guideProcessor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = expectedProcessedCustomFormatData, + QualityProfiles = config[0].QualityProfiles + }, + new() + { + CustomFormats = expectedProcessedCustomFormatData.GetRange(2, 1), + QualityProfiles = config[1].QualityProfiles + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + + guideProcessor.CustomFormatsWithoutScore.Should() + .Equal(new List<(string name, string trashId, string profileName)> + { + ("No Score", "abc", "profile1"), + ("No Score", "abc", "profile3") + }); + + guideProcessor.CustomFormatsNotInGuide.Should().Equal(new List + { + "not in guide 1", "not in guide 2" + }); + + guideProcessor.ProfileScores.Should() + .BeEquivalentTo(new Dictionary> + { + { + "profile1", new List + { + new(expectedProcessedCustomFormatData[0], 500), + new(expectedProcessedCustomFormatData[1], 480) + } + }, + { + "profile2", new List + { + new(expectedProcessedCustomFormatData[0], -1234), + new(expectedProcessedCustomFormatData[1], -1234), + new(expectedProcessedCustomFormatData[2], -1234) + } + }, + { + "profile4", new List + { + new(expectedProcessedCustomFormatData[2], 5678) + } + } + }, op => op.Using(new JsonEquivalencyStep())); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs new file mode 100644 index 00000000..8da1aed2 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs @@ -0,0 +1,211 @@ +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Trash.Radarr; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class ConfigStepTest + { + [Test] + public void All_custom_formats_found_in_guide() + { + var testProcessedCfs = new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) + { + Score = 100 + }, + new("name3", "id3", JObject.FromObject(new {name = "name3"})) + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1", "name3"}, + QualityProfiles = new List + { + new() {Name = "profile1", Score = 50} + } + } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.RenamedCustomFormats.Should().BeEmpty(); + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = testProcessedCfs, + QualityProfiles = testConfig[0].QualityProfiles + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + } + + [Test] + public void Cache_names_are_used_instead_of_name_in_json_data() + { + var testProcessedCfs = new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) + { + Score = 100 + }, + new("name3", "id3", JObject.FromObject(new {name = "name3"})) + { + CacheEntry = new TrashIdMapping("id3", "name1") + } + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1"} + } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = new List + {testProcessedCfs[1]} + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + } + + [Test] + public void Custom_formats_missing_from_config_are_skipped() + { + var testProcessedCfs = new List + { + new("name1", "", new JObject()), + new("name2", "", new JObject()) + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1"} + } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.RenamedCustomFormats.Should().BeEmpty(); + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = new List + { + new("name1", "", new JObject()) + } + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + } + + [Test] + public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list() + { + var testProcessedCfs = new List + { + new("name1", "", new JObject()), + new("name2", "", new JObject()) + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1", "name3"} + } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.RenamedCustomFormats.Should().BeEmpty(); + processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List {"name3"}, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = new List + { + new("name1", "", new JObject()) + } + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + } + + [Test] + public void Custom_formats_with_same_trash_id_and_same_name_in_cache_are_in_renamed_list() + { + var testProcessedCfs = new List + { + new("name1", "id1", new JObject()) + { + CacheEntry = new TrashIdMapping("id1", "name2") + }, + new("name2", "id2", new JObject()) + { + CacheEntry = new TrashIdMapping("id2", "name1") + } + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1", "name2"} + } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.RenamedCustomFormats.Should().BeEquivalentTo(testProcessedCfs, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = testProcessedCfs + } + }, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs new file mode 100644 index 00000000..fd7f84dd --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using TestLibrary.FluentAssertions; +using Trash.Radarr; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class CustomFormatStepTest + { + private class Context + { + public List TestGuideData { get; } = new() + { + new CustomFormatData + { + Score = 100, + Json = JsonConvert.SerializeObject(new + { + trash_id = "id1", + name = "name1" + }, Formatting.Indented) + }, + new CustomFormatData + { + Score = 200, + Json = JsonConvert.SerializeObject(new + { + trash_id = "id2", + name = "name2" + }, Formatting.Indented) + }, + new CustomFormatData + { + Json = JsonConvert.SerializeObject(new + { + trash_id = "id3", + name = "name3" + }, Formatting.Indented) + } + }; + } + + [TestCase("name1", 0)] + [TestCase("naME1", 0)] + [TestCase("DifferentName", 1)] + public void Match_cf_in_guide_with_different_name_with_cache_using_same_name_in_config(string variableCfName, + int outdatedCount) + { + var testConfig = new List + { + new() {Names = new List {"name1"}} + }; + + var testCache = new CustomFormatCache + { + TrashIdMappings = new List + { + new("id1", "name1") + } + }; + + var testGuideData = new List + { + new() + { + Score = 100, + Json = JsonConvert.SerializeObject(new + { + trash_id = "id1", + name = variableCfName + }, Formatting.Indented) + } + }; + + var processor = new CustomFormatStep(); + processor.Process(testGuideData, testConfig, testCache); + + processor.CustomFormatsWithOutdatedNames.Should().HaveCount(outdatedCount); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new(variableCfName, "id1", JObject.FromObject(new {name = variableCfName})) + { + Score = 100, + CacheEntry = testCache.TrashIdMappings[0] + } + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Cache_entry_is_not_set_when_id_is_different() + { + var guideData = new List + { + new() + { + Json = @"{'name': 'name1', 'trash_id': 'id1'}" + } + }; + + var testConfig = new List + { + new() {Names = new List {"name1"}} + }; + + var testCache = new CustomFormatCache + { + TrashIdMappings = new List + { + new("id1000", "name1") + } + }; + + var processor = new CustomFormatStep(); + processor.Process(guideData, testConfig, testCache); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Count.Should().Be(1); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) + { + Score = null, + CacheEntry = null + } + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Cfs_not_in_config_are_skipped() + { + var ctx = new Context(); + var testConfig = new List + { + new() {Names = new List {"name1", "name3"}} + }; + + var processor = new CustomFormatStep(); + processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) + { + Score = 100 + }, + new("name3", "id3", JObject.FromObject(new {name = "name3"})) + { + Score = null + } + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Config_cfs_in_different_sections_are_processed() + { + var ctx = new Context(); + var testConfig = new List + { + new() {Names = new List {"name1", "name3"}}, + new() {Names = new List {"name2"}} + }; + + var processor = new CustomFormatStep(); + processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100}, + new("name2", "id2", JObject.FromObject(new {name = "name2"})) {Score = 200}, + new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null} + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Custom_format_is_deleted_if_in_config_and_cache_but_not_in_guide() + { + var guideData = new List + { + new() + { + Json = @"{'name': 'name1', 'trash_id': 'id1'}" + } + }; + + var testConfig = new List + { + new() {Names = new List {"name1"}} + }; + + var testCache = new CustomFormatCache + { + TrashIdMappings = new List {new("id1000", "name1")} + }; + + var processor = new CustomFormatStep(); + processor.Process(guideData, testConfig, testCache); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should() + .BeEquivalentTo(new TrashIdMapping("id1000", "name1")); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.Parse(@"{'name': 'name1'}")) + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Custom_format_is_deleted_if_not_in_config_but_in_cache_and_in_guide() + { + var cache = new CustomFormatCache + { + TrashIdMappings = new List {new("id1", "3D", 9)} + }; + + var guideCfs = new List + { + new() {Json = "{'name': '3D', 'trash_id': 'id1'}"} + }; + + var processor = new CustomFormatStep(); + processor.Process(guideCfs, Array.Empty(), cache); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEquivalentTo(cache.TrashIdMappings[0]); + processor.ProcessedCustomFormats.Should().BeEmpty(); + } + + [Test] + public void Custom_format_name_in_cache_is_updated_if_renamed_in_guide_and_config() + { + var guideData = new List + { + new() + { + Json = @"{'name': 'name2', 'trash_id': 'id1'}" + } + }; + + var testConfig = new List + { + new() {Names = new List {"name2"}} + }; + + var testCache = new CustomFormatCache + { + TrashIdMappings = new List {new("id1", "name1")} + }; + + var processor = new CustomFormatStep(); + processor.Process(guideData, testConfig, testCache); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should() + .ContainSingle().Which.CacheEntry.Should() + .BeEquivalentTo(new TrashIdMapping("id1", "name2")); + } + + [Test] + public void Match_cf_names_regardless_of_case_in_config() + { + var ctx = new Context(); + var testConfig = new List + { + new() {Names = new List {"name1", "NAME1"}} + }; + + var processor = new CustomFormatStep(); + processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100} + }, + op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Non_existent_cfs_in_config_are_skipped() + { + var ctx = new Context(); + var testConfig = new List + { + new() {Names = new List {"doesnt_exist"}} + }; + + var processor = new CustomFormatStep(); + processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEmpty(); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs new file mode 100644 index 00000000..d02fc95a --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Trash.Radarr; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class QualityProfileStepTest + { + [Test] + public void No_score_used_if_no_score_in_config_or_guide() + { + var testConfigData = new List + { + new() + { + CustomFormats = new List + { + new("name1", "id1", new JObject()) {Score = null} + }, + QualityProfiles = new List + { + new() {Name = "profile1"} + } + } + }; + + var processor = new QualityProfileStep(); + processor.Process(testConfigData); + + processor.ProfileScores.Should().BeEmpty(); + processor.CustomFormatsWithoutScore.Should().Equal(new List {("name1", "id1", "profile1")}); + } + + [Test] + public void Overwrite_score_from_guide_if_config_defines_score() + { + var testConfigData = new List + { + new() + { + CustomFormats = new List + { + new("", "id1", new JObject()) {Score = 100} + }, + QualityProfiles = new List + { + new() {Name = "profile1", Score = 50} + } + } + }; + + var processor = new QualityProfileStep(); + processor.Process(testConfigData); + + processor.ProfileScores.Should().ContainKey("profile1") + .WhichValue.Should().BeEquivalentTo(new List + { + new(testConfigData[0].CustomFormats[0], 50) + }); + + processor.CustomFormatsWithoutScore.Should().BeEmpty(); + } + + [Test] + public void Use_guide_score_if_no_score_in_config() + { + var testConfigData = new List + { + new() + { + CustomFormats = new List + { + new("", "id1", new JObject()) {Score = 100} + }, + QualityProfiles = new List + { + new() {Name = "profile1"}, + new() {Name = "profile2", Score = null} + } + } + }; + + var processor = new QualityProfileStep(); + processor.Process(testConfigData); + + var expectedScoreEntries = new List + { + new(testConfigData[0].CustomFormats[0], 100) + }; + + processor.ProfileScores.Should().BeEquivalentTo( + new Dictionary> + { + {"profile1", expectedScoreEntries}, + {"profile2", expectedScoreEntries} + }); + + processor.CustomFormatsWithoutScore.Should().BeEmpty(); + } + + [Test] + public void Zero_score_is_not_ignored() + { + var testConfigData = new List + { + new() + { + CustomFormats = new List + { + new("name1", "id1", new JObject()) {Score = 0} + }, + QualityProfiles = new List + { + new() {Name = "profile1"} + } + } + }; + + var processor = new QualityProfileStep(); + processor.Process(testConfigData); + + processor.ProfileScores.Should().ContainKey("profile1") + .WhichValue.Should().BeEquivalentTo(new List + { + new(testConfigData[0].CustomFormats[0], 0) + }); + + processor.CustomFormatsWithoutScore.Should().BeEmpty(); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs new file mode 100644 index 00000000..efae91d3 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; +using Trash.Radarr; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors; + +namespace Trash.Tests.Radarr.CustomFormat.Processors +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class PersistenceProcessorTest + { + [Test] + public void Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config() + { + var steps = Substitute.For(); + var cfApi = Substitute.For(); + var qpApi = Substitute.For(); + var config = new RadarrConfiguration {DeleteOldCustomFormats = true}; + + var guideCfs = Array.Empty(); + var deletedCfsInCache = new Collection(); + var profileScores = new Dictionary>(); + + var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps); + processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores); + + steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any>()); + } + + [Test] + public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config() + { + var steps = Substitute.For(); + var cfApi = Substitute.For(); + var qpApi = Substitute.For(); + var config = new RadarrConfiguration(); // DeleteOldCustomFormats should default to false + + var guideCfs = Array.Empty(); + var deletedCfsInCache = Array.Empty(); + var profileScores = new Dictionary>(); + + var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps); + processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores); + + steps.JsonTransactionStep.DidNotReceive() + .RecordDeletions(Arg.Any>(), Arg.Any>()); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStepTest.cs new file mode 100644 index 00000000..73940349 --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStepTest.cs @@ -0,0 +1,47 @@ +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class CustomFormatApiPersistenceStepTest + { + private ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId) + { + return new(cfName, trashId, new JObject()) + { + CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId} + }; + } + + [Test] + public async Task All_api_operations_behave_normally() + { + var transactions = new CustomFormatTransactionData(); + transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1)); + transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2)); + transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3)); + transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4}); + + var api = Substitute.For(); + + var processor = new CustomFormatApiPersistenceStep(); + await processor.Process(api, transactions); + + Received.InOrder(() => + { + api.CreateCustomFormat(transactions.NewCustomFormats.First()); + api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First()); + api.DeleteCustomFormat(4); + }); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStepTest.cs new file mode 100644 index 00000000..e8d7be9c --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStepTest.cs @@ -0,0 +1,366 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using TestLibrary.FluentAssertions; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +/* Sample Custom Format response from Radarr API +{ + "id": 1, + "name": "test", + "includeCustomFormatWhenRenaming": false, + "specifications": [ + { + "name": "asdf", + "implementation": "ReleaseTitleSpecification", + "implementationName": "Release Title", + "infoLink": "https://wiki.servarr.com/Radarr_Settings#Custom_Formats_2", + "negate": false, + "required": false, + "fields": [ + { + "order": 0, + "name": "value", + "label": "Regular Expression", + "value": "asdf", + "type": "textbox", + "advanced": false + } + ] + } + ] +} +*/ + +namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class JsonTransactionStepTest + { + [TestCase(1, "cf2")] + [TestCase(2, "cf1")] + [TestCase(null, "cf1")] + public void Updates_using_combination_of_id_and_name(int? id, string guideCfName) + { + const string radarrCfData = @"{ + 'id': 1, + 'name': 'cf1', + 'specifications': [{ + 'name': 'spec1', + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}"; + var guideCfData = JObject.Parse(@"{ + 'name': 'cf1', + 'specifications': [{ + 'name': 'spec1', + 'new': 'valuenew', + 'fields': { + 'value': 'value2' + } + }] +}"); + var cacheEntry = id != null ? new TrashIdMapping("", "") {CustomFormatId = id.Value} : null; + + var guideCfs = new List + { + new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry} + }; + + var processor = new JsonTransactionStep(); + processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)}); + + var expectedTransactions = new CustomFormatTransactionData(); + expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]); + processor.Transactions.Should().BeEquivalentTo(expectedTransactions); + + const string expectedJsonData = @"{ + 'id': 1, + 'name': 'cf1', + 'specifications': [{ + 'name': 'spec1', + 'new': 'valuenew', + 'fields': [{ + 'name': 'value', + 'value': 'value2' + }] + }] +}"; + processor.Transactions.UpdatedCustomFormats.First().Json.Should() + .BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Combination_of_create_update_and_no_change_and_verify_proper_json_merging() + { + const string radarrCfData = @"[{ + 'id': 1, + 'name': 'user_defined', + 'specifications': [{ + 'name': 'spec1', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}, { + 'id': 2, + 'name': 'updated', + 'specifications': [{ + 'name': 'spec2', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'untouchable': 'field', + 'value': 'value1' + }] + }] +}, { + 'id': 3, + 'name': 'no_change', + 'specifications': [{ + 'name': 'spec4', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}]"; + var guideCfData = JsonConvert.DeserializeObject>(@"[{ + 'name': 'created', + 'specifications': [{ + 'name': 'spec5', + 'fields': { + 'value': 'value2' + } + }] +}, { + 'name': 'updated_different_name', + 'specifications': [{ + 'name': 'spec2', + 'negate': true, + 'new_spec_field': 'new_spec_value', + 'fields': { + 'value': 'value2', + 'new_field': 'new_value' + } + }, { + 'name': 'new_spec', + 'fields': { + 'value': 'value3' + } + }] +}, { + 'name': 'no_change', + 'specifications': [{ + 'name': 'spec4', + 'negate': false, + 'fields': { + 'value': 'value1' + } + }] +}]"); + + var radarrCfs = JsonConvert.DeserializeObject>(radarrCfData); + var guideCfs = new List + { + new("created", "", guideCfData[0]), + new("updated_different_name", "", guideCfData[1]) + { + CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 2} + }, + new("no_change", "", guideCfData[2]) + }; + + var processor = new JsonTransactionStep(); + processor.Process(guideCfs, radarrCfs); + + var expectedJson = new[] + { + @"{ + 'name': 'created', + 'specifications': [{ + 'name': 'spec5', + 'fields': [{ + 'name': 'value', + 'value': 'value2' + }] + }] +}", + @"{ + 'id': 2, + 'name': 'updated_different_name', + 'specifications': [{ + 'name': 'spec2', + 'negate': true, + 'new_spec_field': 'new_spec_value', + 'fields': [{ + 'name': 'value', + 'untouchable': 'field', + 'value': 'value2', + 'new_field': 'new_value' + }] + }, { + 'name': 'new_spec', + 'fields': [{ + 'name': 'value', + 'value': 'value3' + }] + }] +}", + @"{ + 'id': 3, + 'name': 'no_change', + 'specifications': [{ + 'name': 'spec4', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}" + }; + + var expectedTransactions = new CustomFormatTransactionData(); + expectedTransactions.NewCustomFormats.Add(guideCfs[0]); + expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]); + expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]); + processor.Transactions.Should().BeEquivalentTo(expectedTransactions); + + processor.Transactions.NewCustomFormats.First().Json.Should() + .BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep())); + + processor.Transactions.UpdatedCustomFormats.First().Json.Should() + .BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep())); + + processor.Transactions.UnchangedCustomFormats.First().Json.Should() + .BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Deletes_happen_before_updates() + { + const string radarrCfData = @"[{ + 'id': 1, + 'name': 'updated', + 'specifications': [{ + 'name': 'spec1', + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}, { + 'id': 2, + 'name': 'deleted', + 'specifications': [{ + 'name': 'spec2', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'untouchable': 'field', + 'value': 'value1' + }] + }] +}]"; + var guideCfData = JObject.Parse(@"{ + 'name': 'updated', + 'specifications': [{ + 'name': 'spec2', + 'fields': { + 'value': 'value2' + } + }] +}"); + var deletedCfsInCache = new List + { + new("", "") {CustomFormatId = 2} + }; + + var guideCfs = new List + { + new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}} + }; + + var radarrCfs = JsonConvert.DeserializeObject>(radarrCfData); + + var processor = new JsonTransactionStep(); + processor.Process(guideCfs, radarrCfs); + processor.RecordDeletions(deletedCfsInCache, radarrCfs); + + var expectedJson = @"{ + 'id': 1, + 'name': 'updated', + 'specifications': [{ + 'name': 'spec2', + 'fields': [{ + 'name': 'value', + 'value': 'value2' + }] + }] +}"; + var expectedTransactions = new CustomFormatTransactionData(); + expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2)); + expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]); + processor.Transactions.Should().BeEquivalentTo(expectedTransactions); + + processor.Transactions.UpdatedCustomFormats.First().Json.Should() + .BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep())); + } + + [Test] + public void Only_delete_correct_cfs() + { + const string radarrCfData = @"[{ + 'id': 1, + 'name': 'not_deleted', + 'specifications': [{ + 'name': 'spec1', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'value': 'value1' + }] + }] +}, { + 'id': 2, + 'name': 'deleted', + 'specifications': [{ + 'name': 'spec2', + 'negate': false, + 'fields': [{ + 'name': 'value', + 'untouchable': 'field', + 'value': 'value1' + }] + }] +}]"; + var deletedCfsInCache = new List + { + new("testtrashid", "testname") {CustomFormatId = 2}, + new("", "not_deleted") {CustomFormatId = 3} + }; + + var radarrCfs = JsonConvert.DeserializeObject>(radarrCfData); + + var processor = new JsonTransactionStep(); + processor.RecordDeletions(deletedCfsInCache, radarrCfs); + + var expectedTransactions = new CustomFormatTransactionData(); + expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2)); + processor.Transactions.Should().BeEquivalentTo(expectedTransactions); + } + } +} diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs new file mode 100644 index 00000000..ca71a6ae --- /dev/null +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using FluentAssertions.Json; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NSubstitute; +using NUnit.Framework; +using TestLibrary.NSubstitute; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class QualityProfileApiPersistenceStepTest + { + [Test] + public void Invalid_quality_profile_names_are_reported() + { + const string radarrQualityProfileData = @"[{'name': 'profile1'}]"; + + var api = Substitute.For(); + api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); + + var cfScores = new Dictionary> + { + {"wrong_profile_name", new List()} + }; + + var processor = new QualityProfileApiPersistenceStep(); + processor.Process(api, cfScores); + + api.DidNotReceive().UpdateQualityProfile(Arg.Any(), Arg.Any()); + processor.InvalidProfileNames.Should().BeEquivalentTo("wrong_profile_name"); + processor.UpdatedScores.Should().BeEmpty(); + } + + [Test] + public void Scores_are_set_in_quality_profile() + { + const string radarrQualityProfileData = @"[{ + 'name': 'profile1', + 'upgradeAllowed': false, + 'cutoff': 20, + 'items': [{ + 'quality': { + 'id': 10, + 'name': 'Raw-HD', + 'source': 'tv', + 'resolution': 1080, + 'modifier': 'rawhd' + }, + 'items': [], + 'allowed': false + } + ], + 'minFormatScore': 0, + 'cutoffFormatScore': 0, + 'formatItems': [{ + 'format': 4, + 'name': '3D', + 'score': 0 + }, + { + 'format': 3, + 'name': 'BR-DISK', + 'score': 0 + }, + { + 'format': 1, + 'name': 'asdf2', + 'score': 0 + } + ], + 'language': { + 'id': 1, + 'name': 'English' + }, + 'id': 1 +}]"; + + var api = Substitute.For(); + api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); + + var cfScores = new Dictionary> + { + { + "profile1", new List + { + new(new ProcessedCustomFormatData("", "", new JObject()) + { + // First match by ID + CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4} + }, 100), + new(new ProcessedCustomFormatData("", "", new JObject()) + { + // Should NOT match because we do not use names to assign scores + CacheEntry = new TrashIdMapping("", "BR-DISK") + }, 101), + new(new ProcessedCustomFormatData("", "", new JObject()) + { + // Second match by ID + CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1} + }, 102) + } + } + }; + + var processor = new QualityProfileApiPersistenceStep(); + processor.Process(api, cfScores); + + var expectedProfileJson = JObject.Parse(@"{ + 'name': 'profile1', + 'upgradeAllowed': false, + 'cutoff': 20, + 'items': [{ + 'quality': { + 'id': 10, + 'name': 'Raw-HD', + 'source': 'tv', + 'resolution': 1080, + 'modifier': 'rawhd' + }, + 'items': [], + 'allowed': false + } + ], + 'minFormatScore': 0, + 'cutoffFormatScore': 0, + 'formatItems': [{ + 'format': 4, + 'name': '3D', + 'score': 100 + }, + { + 'format': 3, + 'name': 'BR-DISK', + 'score': 0 + }, + { + 'format': 1, + 'name': 'asdf2', + 'score': 102 + } + ], + 'language': { + 'id': 1, + 'name': 'English' + }, + 'id': 1 +}"); + + api.Received() + .UpdateQualityProfile(Verify.That(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1); + processor.InvalidProfileNames.Should().BeEmpty(); + processor.UpdatedScores.Should().ContainKey("profile1").WhichValue.Should().BeEquivalentTo( + cfScores.Values.First()[0], + cfScores.Values.First()[2]); + } + } +} diff --git a/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs b/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs index cd42ef88..dea2aa64 100644 --- a/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs +++ b/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs @@ -16,7 +16,28 @@ namespace Trash.Tests.Radarr public class RadarrConfigurationTest { [Test] - public void Deserialize_QualityDefinitionTypeMissing_Throw() + public void Custom_format_names_list_is_required() + { + const string testYaml = @" +radarr: + - api_key: abc + base_url: xyz + custom_formats: + - quality_profiles: + - name: MyProfile +"; + + var configLoader = new ConfigurationLoader( + Substitute.For(), + Substitute.For(), new DefaultObjectFactory()); + + Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr"); + + act.Should().Throw(); + } + + [Test] + public void Quality_definition_type_is_required() { const string yaml = @" radarr: @@ -35,5 +56,27 @@ radarr: act.Should().Throw() .WithMessage("*'type' is required for 'quality_definition'"); } + + [Test] + public void Quality_profile_name_is_required() + { + const string testYaml = @" +radarr: + - api_key: abc + base_url: xyz + custom_formats: + - names: [one, two] + quality_profiles: + - score: 100 +"; + + var configLoader = new ConfigurationLoader( + Substitute.For(), + Substitute.For(), new DefaultObjectFactory()); + + Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr"); + + act.Should().Throw(); + } } } diff --git a/src/Trash.Tests/Trash.Tests.csproj b/src/Trash.Tests/Trash.Tests.csproj index 15dfca36..3e880dbd 100644 --- a/src/Trash.Tests/Trash.Tests.csproj +++ b/src/Trash.Tests/Trash.Tests.csproj @@ -7,4 +7,8 @@ + + + + diff --git a/src/Trash/AppPaths.cs b/src/Trash/AppPaths.cs index 513a775e..569d8330 100644 --- a/src/Trash/AppPaths.cs +++ b/src/Trash/AppPaths.cs @@ -6,8 +6,8 @@ namespace Trash internal static class AppPaths { public static string AppDataPath { get; } = - Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater"); + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater"); - public static string DefaultConfigPath { get; } = Path.Join(AppContext.BaseDirectory, "trash.yml"); + public static string DefaultConfigPath { get; } = Path.Combine(AppContext.BaseDirectory, "trash.yml"); } } diff --git a/src/Trash/Cache/ServiceCache.cs b/src/Trash/Cache/ServiceCache.cs index d962e505..bcdaa010 100644 --- a/src/Trash/Cache/ServiceCache.cs +++ b/src/Trash/Cache/ServiceCache.cs @@ -70,7 +70,7 @@ namespace Trash.Cache throw new ArgumentException($"Object name '{objectName}' has unacceptable characters"); } - return Path.Join(_storagePath.Path, BuildServiceGuid(), objectName + ".json"); + return Path.Combine(_storagePath.Path, BuildServiceGuid(), objectName + ".json"); } } } diff --git a/src/Trash/CompositionRoot.cs b/src/Trash/CompositionRoot.cs index 0a7dcf71..9b5e2212 100644 --- a/src/Trash/CompositionRoot.cs +++ b/src/Trash/CompositionRoot.cs @@ -1,14 +1,21 @@ using System.IO.Abstractions; using System.Reflection; using Autofac; +using Autofac.Extras.AggregateService; using CliFx; using Serilog; using Serilog.Core; using Trash.Cache; using Trash.Command; using Trash.Config; -using Trash.Radarr.Api; +using Trash.Radarr.CustomFormat; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Processors; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; using Trash.Radarr.QualityDefinition; +using Trash.Radarr.QualityDefinition.Api; using Trash.Sonarr.Api; using Trash.Sonarr.QualityDefinition; using Trash.Sonarr.ReleaseProfile; @@ -47,11 +54,33 @@ namespace Trash private static void RadarrRegistrations(ContainerBuilder builder) { - builder.RegisterType().As(); + // Api Services + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); // Quality Definition Support builder.RegisterType(); builder.RegisterType().As(); + + // Custom Format Support + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + + // Guide Processor + builder.RegisterType().As(); + builder.RegisterAggregateService(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + + // Persistence Processor + builder.RegisterType().As(); + builder.RegisterAggregateService(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); } private static void ConfigurationRegistrations(ContainerBuilder builder) diff --git a/src/Trash/Extensions/LinqExtensions.cs b/src/Trash/Extensions/LinqExtensions.cs new file mode 100644 index 00000000..9f6e832b --- /dev/null +++ b/src/Trash/Extensions/LinqExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Trash.Extensions +{ + internal static class LinqExtensions + { + internal static IEnumerable FullOuterGroupJoin( + this IEnumerable a, + IEnumerable b, + Func selectKeyA, + Func selectKeyB, + Func, IEnumerable, TKey, TResult> projection, + IEqualityComparer? cmp = null) + { + cmp ??= EqualityComparer.Default; + var alookup = a.ToLookup(selectKeyA, cmp); + var blookup = b.ToLookup(selectKeyB, cmp); + + var keys = new HashSet(alookup.Select(p => p.Key), cmp); + keys.UnionWith(blookup.Select(p => p.Key)); + + var join = from key in keys + let xa = alookup[key] + let xb = blookup[key] + select projection(xa, xb, key); + + return join; + } + + internal static IEnumerable FullOuterJoin( + this IEnumerable a, + IEnumerable b, + Func selectKeyA, + Func selectKeyB, + Func projection, + TA? defaultA = default, + TB? defaultB = default, + IEqualityComparer? cmp = null) + { + cmp ??= EqualityComparer.Default; + var alookup = a.ToLookup(selectKeyA, cmp); + var blookup = b.ToLookup(selectKeyB, cmp); + + var keys = new HashSet(alookup.Select(p => p.Key), cmp); + keys.UnionWith(blookup.Select(p => p.Key)); + + var join = from key in keys + from xa in alookup[key].DefaultIfEmpty(defaultA) + from xb in blookup[key].DefaultIfEmpty(defaultB) + select projection(xa, xb, key); + + return join; + } + } +} diff --git a/src/Trash/Extensions/StringExtensions.cs b/src/Trash/Extensions/StringExtensions.cs index 4ff424c8..96de8057 100644 --- a/src/Trash/Extensions/StringExtensions.cs +++ b/src/Trash/Extensions/StringExtensions.cs @@ -10,7 +10,7 @@ namespace Trash.Extensions return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase); } - public static bool EqualsIgnoreCase(this string value, string matchThis) + public static bool EqualsIgnoreCase(this string value, string? matchThis) { return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase); } diff --git a/src/Trash/Radarr/CustomFormat/Api/CustomFormatService.cs b/src/Trash/Radarr/CustomFormat/Api/CustomFormatService.cs new file mode 100644 index 00000000..9d0cd256 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Api/CustomFormatService.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Flurl; +using Flurl.Http; +using Newtonsoft.Json.Linq; +using Trash.Config; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Api +{ + internal class CustomFormatService : ICustomFormatService + { + private readonly IServiceConfiguration _serviceConfig; + + public CustomFormatService(IServiceConfiguration serviceConfig) + { + _serviceConfig = serviceConfig; + } + + public async Task> GetCustomFormats() + { + return await BaseUrl() + .AppendPathSegment("customformat") + .GetJsonAsync>(); + } + + public async Task CreateCustomFormat(ProcessedCustomFormatData cf) + { + var response = await BaseUrl() + .AppendPathSegment("customformat") + .PostJsonAsync(cf.Json) + .ReceiveJson(); + + cf.SetCache((int) response["id"]); + } + + public async Task UpdateCustomFormat(ProcessedCustomFormatData cf) + { + // Set the cache first, since it's needed to perform the update. This case will apply to CFs we update that + // exist in Radarr but not the cache (e.g. moving to a new machine, same-named CF was created manually) + if (cf.CacheEntry == null) + { + cf.SetCache((int) cf.Json["id"]); + } + + await BaseUrl() + .AppendPathSegment($"customformat/{cf.GetCustomFormatId()}") + .PutJsonAsync(cf.Json) + .ReceiveJson(); + } + + public async Task DeleteCustomFormat(int customFormatId) + { + await BaseUrl() + .AppendPathSegment($"customformat/{customFormatId}") + .DeleteAsync(); + } + + private string BaseUrl() + { + return _serviceConfig.BuildUrl(); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Api/ICustomFormatService.cs b/src/Trash/Radarr/CustomFormat/Api/ICustomFormatService.cs new file mode 100644 index 00000000..626f7505 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Api/ICustomFormatService.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Api +{ + public interface ICustomFormatService + { + Task> GetCustomFormats(); + Task CreateCustomFormat(ProcessedCustomFormatData cf); + Task UpdateCustomFormat(ProcessedCustomFormatData cf); + Task DeleteCustomFormat(int customFormatId); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Api/IQualityProfileService.cs b/src/Trash/Radarr/CustomFormat/Api/IQualityProfileService.cs new file mode 100644 index 00000000..320c65e1 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Api/IQualityProfileService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace Trash.Radarr.CustomFormat.Api +{ + public interface IQualityProfileService + { + Task> GetQualityProfiles(); + Task UpdateQualityProfile(JObject profileJson, int id); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Api/QualityProfileService.cs b/src/Trash/Radarr/CustomFormat/Api/QualityProfileService.cs new file mode 100644 index 00000000..7a306b38 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Api/QualityProfileService.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Flurl; +using Flurl.Http; +using Newtonsoft.Json.Linq; +using Trash.Config; + +namespace Trash.Radarr.CustomFormat.Api +{ + internal class QualityProfileService : IQualityProfileService + { + private readonly IServiceConfiguration _serviceConfig; + + public QualityProfileService(IServiceConfiguration serviceConfig) + { + _serviceConfig = serviceConfig; + } + + private string BaseUrl => _serviceConfig.BuildUrl(); + + public async Task> GetQualityProfiles() + { + return await BaseUrl + .AppendPathSegment("qualityprofile") + .GetJsonAsync>(); + } + + public async Task UpdateQualityProfile(JObject profileJson, int id) + { + return await BaseUrl + .AppendPathSegment($"qualityprofile/{id}") + .PutJsonAsync(profileJson) + .ReceiveJson(); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/ApiOperationType.cs b/src/Trash/Radarr/CustomFormat/ApiOperationType.cs new file mode 100644 index 00000000..79ffbb0b --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/ApiOperationType.cs @@ -0,0 +1,10 @@ +namespace Trash.Radarr.CustomFormat +{ + public enum ApiOperationType + { + Create, + Update, + NoChange, + Delete + } +} diff --git a/src/Trash/Radarr/CustomFormat/CachePersister.cs b/src/Trash/Radarr/CustomFormat/CachePersister.cs new file mode 100644 index 00000000..4852de7f --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/CachePersister.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Serilog; +using Trash.Cache; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat +{ + public class CachePersister : ICachePersister + { + private readonly IServiceCache _cache; + + public CachePersister(ILogger log, IServiceCache cache) + { + Log = log; + _cache = cache; + } + + private ILogger Log { get; } + public CustomFormatCache? CfCache { get; private set; } + + public void Load() + { + CfCache = _cache.Load(); + // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression + if (CfCache != null) + { + Log.Debug("Loaded Cache"); + } + else + { + Log.Debug("Custom format cache does not exist; proceeding without it"); + } + } + + public void Save() + { + if (CfCache == null) + { + Log.Debug("Not saving cache because it is null"); + return; + } + + Log.Debug("Saving Cache"); + _cache.Save(CfCache); + } + + public void Update(IEnumerable customFormats) + { + Log.Debug("Updating cache"); + CfCache = new CustomFormatCache(); + CfCache!.TrashIdMappings.AddRange(customFormats + .Where(cf => cf.CacheEntry != null) + .Select(cf => cf.CacheEntry!)); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs new file mode 100644 index 00000000..72cb18bf --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs @@ -0,0 +1,241 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Serilog; +using Trash.Command; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Processors; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Radarr.CustomFormat +{ + internal class CustomFormatUpdater : ICustomFormatUpdater + { + private readonly ICachePersister _cache; + private readonly IGuideProcessor _guideProcessor; + private readonly IPersistenceProcessor _persistenceProcessor; + + public CustomFormatUpdater( + ILogger log, + ICachePersister cache, + IGuideProcessor guideProcessor, + IPersistenceProcessor persistenceProcessor) + { + Log = log; + _cache = cache; + _guideProcessor = guideProcessor; + _persistenceProcessor = persistenceProcessor; + } + + private ILogger Log { get; } + + public async Task Process(IServiceCommand args, RadarrConfiguration config) + { + _cache.Load(); + + await _guideProcessor.BuildGuideData(config.CustomFormats, _cache.CfCache); + + if (!ValidateGuideDataAndCheckShouldProceed(config)) + { + return; + } + + if (args.Preview) + { + PreviewCustomFormats(); + return; + } + + await _persistenceProcessor.PersistCustomFormats(_guideProcessor.ProcessedCustomFormats, + _guideProcessor.DeletedCustomFormatsInCache, _guideProcessor.ProfileScores); + + PrintApiStatistics(args, _persistenceProcessor.Transactions); + PrintQualityProfileUpdates(); + + // Cache all the custom formats (using ID from API response). + _cache.Update(_guideProcessor.ProcessedCustomFormats); + _cache.Save(); + + _persistenceProcessor.Reset(); + _guideProcessor.Reset(); + } + + private void PrintQualityProfileUpdates() + { + if (_persistenceProcessor.UpdatedScores.Count > 0) + { + foreach (var (profileName, scores) in _persistenceProcessor.UpdatedScores) + { + Log.Debug("> Scores updated for quality profile: {ProfileName}", profileName); + + foreach (var score in scores) + { + Log.Debug(" - {Format}: {Score}", score.CustomFormat.Name, score.Score); + } + } + + Log.Information("Updated {ProfileCount} profiles and a total of {ScoreCount} scores", + _persistenceProcessor.UpdatedScores.Keys.Count, + _persistenceProcessor.UpdatedScores.Sum(s => s.Value.Count)); + } + else + { + Log.Information("All quality profile scores are already up to date!"); + } + + if (_persistenceProcessor.InvalidProfileNames.Count > 0) + { + Log.Warning("The following quality profile names are not valid and should either be " + + "removed or renamed in your YAML config"); + Log.Warning("{QualityProfileNames}", _persistenceProcessor.InvalidProfileNames); + } + } + + private void PrintApiStatistics(IServiceCommand args, CustomFormatTransactionData transactions) + { + var created = transactions.NewCustomFormats; + if (created.Count > 0) + { + Log.Information("Created {Count} New Custom Formats: {CustomFormats}", created.Count, + created.Select(r => r.Name)); + } + + var updated = transactions.UpdatedCustomFormats; + if (updated.Count > 0) + { + Log.Information("Updated {Count} Existing Custom Formats: {CustomFormats}", updated.Count, + updated.Select(r => r.Name)); + } + + if (args.Debug) + { + var skipped = transactions.UnchangedCustomFormats; + if (skipped.Count > 0) + { + Log.Debug("Skipped {Count} Custom Formats that did not change: {CustomFormats}", skipped.Count, + skipped.Select(r => r.Name)); + } + } + + var deleted = transactions.DeletedCustomFormatIds; + if (deleted.Count > 0) + { + Log.Information("Deleted {Count} Custom Formats: {CustomFormats}", deleted.Count, + deleted.Select(r => r.CustomFormatName)); + } + + var totalCount = created.Count + updated.Count; + if (totalCount > 0) + { + Log.Information("Total of {Count} custom formats synced to Radarr", totalCount); + } + else + { + Log.Information("All custom formats are already up to date!"); + } + } + + private bool ValidateGuideDataAndCheckShouldProceed(RadarrConfiguration config) + { + if (_guideProcessor.CustomFormatsNotInGuide.Count > 0) + { + Log.Warning("The Custom Formats below do not exist in the guide and will " + + "be skipped. Names must match the 'name' field in the actual JSON, not the header in " + + "the guide! Either fix the names or remove them from your YAML config to resolve this " + + "warning"); + Log.Warning("{CfList}", _guideProcessor.CustomFormatsNotInGuide); + } + + var cfsWithoutQualityProfiles = _guideProcessor.ConfigData + .Where(d => d.QualityProfiles.Count == 0) + .SelectMany(d => d.CustomFormats.Select(cf => cf.Name)) + .ToList(); + + if (cfsWithoutQualityProfiles.Count > 0) + { + Log.Debug("These custom formats will be uploaded but are not associated to a quality profile in the " + + "config file: {UnassociatedCfs}", cfsWithoutQualityProfiles); + } + + // No CFs are defined in this item, or they are all invalid. Skip this whole instance. + if (_guideProcessor.ConfigData.Count == 0) + { + Log.Error("Guide processing yielded no custom formats for configured instance host {BaseUrl}", + config.BaseUrl); + return false; + } + + if (_guideProcessor.CustomFormatsWithoutScore.Count > 0) + { + Log.Warning("The below custom formats have no score in the guide or YAML " + + "config and will be skipped (remove them from your config or specify a " + + "score to fix this warning)"); + Log.Warning("{CfList}", _guideProcessor.CustomFormatsWithoutScore); + } + + if (_guideProcessor.CustomFormatsWithOutdatedNames.Count > 0) + { + Log.Warning("One or more custom format names in your YAML config have been renamed in the guide and " + + "are outdated. Each outdated name will be listed below. These custom formats will refuse " + + "to sync if your cache is deleted. To fix this warning, rename each one to its new name"); + + foreach (var (oldName, newName) in _guideProcessor.CustomFormatsWithOutdatedNames) + { + Log.Warning(" - '{OldName}' -> '{NewName}'", oldName, newName); + } + } + + return true; + } + + private void PreviewCustomFormats() + { + Console.WriteLine(""); + Console.WriteLine("========================================================="); + Console.WriteLine(" >>> Custom Formats From Guide <<< "); + Console.WriteLine("========================================================="); + Console.WriteLine(""); + + const string format = "{0,-30} {1,-35}"; + Console.WriteLine(format, "Custom Format", "Trash ID"); + Console.WriteLine(string.Concat(Enumerable.Repeat('-', 1 + 30 + 35))); + + foreach (var cf in _guideProcessor.ProcessedCustomFormats) + { + Console.WriteLine(format, cf.Name, cf.TrashId); + } + + Console.WriteLine(""); + Console.WriteLine("========================================================="); + Console.WriteLine(" >>> Quality Profile Assignments & Scores <<< "); + Console.WriteLine("========================================================="); + Console.WriteLine(""); + + const string profileFormat = "{0,-18} {1,-20} {2,-8}"; + Console.WriteLine(profileFormat, "Profile", "Custom Format", "Score"); + Console.WriteLine(string.Concat(Enumerable.Repeat('-', 2 + 18 + 20 + 8))); + + foreach (var (profileName, scoreEntries) in _guideProcessor.ProfileScores) + { + Console.WriteLine(profileFormat, profileName, "", ""); + + foreach (var scoreEntry in scoreEntries) + { + var matchingCf = _guideProcessor.ProcessedCustomFormats + .FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(scoreEntry.CustomFormat.TrashId)); + + if (matchingCf == null) + { + Log.Warning("Quality Profile refers to CF not found in guide: {TrashId}", + scoreEntry.CustomFormat.TrashId); + continue; + } + + Console.WriteLine(profileFormat, "", matchingCf.Name, scoreEntry.Score); + } + } + + Console.WriteLine(""); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Guide/CustomFormatData.cs b/src/Trash/Radarr/CustomFormat/Guide/CustomFormatData.cs new file mode 100644 index 00000000..ed61f9ab --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Guide/CustomFormatData.cs @@ -0,0 +1,8 @@ +namespace Trash.Radarr.CustomFormat.Guide +{ + public class CustomFormatData + { + public int? Score { get; set; } = null; + public string Json { get; set; } = ""; + } +} diff --git a/src/Trash/Radarr/CustomFormat/Guide/CustomFormatGuideParser.cs b/src/Trash/Radarr/CustomFormat/Guide/CustomFormatGuideParser.cs new file mode 100644 index 00000000..0cf7e99e --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Guide/CustomFormatGuideParser.cs @@ -0,0 +1,149 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Flurl.Http; +using Serilog; +using Trash.Extensions; + +namespace Trash.Radarr.CustomFormat.Guide +{ + public class CustomFormatGuideParser : ICustomFormatGuideParser + { + private readonly Regex _regexFence = BuildRegex(@"(\s*)```(json)?"); + private readonly Regex _regexPotentialScore = BuildRegex(@"\[(-?[\d]+)\]"); + private readonly Regex _regexScore = BuildRegex(@"score.*?\[(-?[\d]+)\]"); + + public CustomFormatGuideParser(ILogger logger) + { + Log = logger; + } + + private ILogger Log { get; } + + public async Task GetMarkdownData() + { + return await + "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Radarr/V3/Radarr-collection-of-custom-formats.md" + .GetStringAsync(); + } + + public IList ParseMarkdown(string markdown) + { + var state = new ParserState(); + + 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 (_regexFence.Match(line, out Match match)) + { + ProcessCodeBlockBoundary(match.Groups, state); + continue; + } + + if (state.CodeBlockIndentation != null) + { + InsideFence_ParseMarkdown(line, state); + } + else + { + OutsideFence_ParseMarkdown(line, state); + } + } + + return state.Results; + } + + private static Regex BuildRegex(string regex) + { + return new(regex, RegexOptions.Compiled | RegexOptions.IgnoreCase); + } + + private void OutsideFence_ParseMarkdown(string line, ParserState state) + { + // ReSharper disable once InlineOutVariableDeclaration + Match match; + + if (_regexScore.Match(line, out match)) + { + state.Score = int.Parse(match.Groups[1].Value); + } + 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 ProcessCodeBlockBoundary(GroupCollection groups, ParserState state) + { + if (groups[2].Value == "json") + { + state.CodeBlockIndentation = groups[1].Value; + } + else + { + // Record previously captured JSON data since we're leaving the code block + var json = state.JsonStream.ToString(); + if (!string.IsNullOrEmpty(json)) + { + state.Results.Add(new CustomFormatData {Json = json, Score = state.Score}); + } + + state.ResetParserState(); + } + } + + private static void InsideFence_ParseMarkdown(string line, ParserState state) + { + state.JsonStream.WriteLine(line[state.CodeBlockIndentation!.Length..]); + } + + // private void OutsideFence_ParseMarkdown(string line, RadarrParserState 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 here if we add more logic below + // } + // } + + // private void OutsideFence_ParseHeader(RadarrParserState state, Match match) + // { + // var headerDepth = match.Groups[1].Length; + // var headerText = match.Groups[2].Value; + // + // var stack = state.HeaderStack; + // while (stack.Count > 0 && stack.Peek().Item1 >= headerDepth) + // { + // stack.Pop(); + // } + // + // if (headerDepth == 0) + // { + // return; + // } + // + // if (state.HeaderStack.TryPeek(out var header)) + // { + // headerText = $"{header.Item2}|{headerText}"; + // } + // + // Log.Debug("> Process Header: {HeaderPath}", headerText); + // state.HeaderStack.Push((headerDepth, headerText)); + // } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Guide/ICustomFormatGuideParser.cs b/src/Trash/Radarr/CustomFormat/Guide/ICustomFormatGuideParser.cs new file mode 100644 index 00000000..59f42dc7 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Guide/ICustomFormatGuideParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Trash.Radarr.CustomFormat.Guide +{ + public interface ICustomFormatGuideParser + { + Task GetMarkdownData(); + IList ParseMarkdown(string markdown); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Guide/ParserState.cs b/src/Trash/Radarr/CustomFormat/Guide/ParserState.cs new file mode 100644 index 00000000..9b125413 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Guide/ParserState.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.IO; + +namespace Trash.Radarr.CustomFormat.Guide +{ + public class ParserState + { + public ParserState() + { + ResetParserState(); + } + + public int? Score { get; set; } + public string? CodeBlockIndentation { get; set; } + public int LineNumber { get; set; } + public List Results { get; } = new(); + public StringWriter JsonStream { get; } = new(); + + public void ResetParserState() + { + CodeBlockIndentation = null; + JsonStream.GetStringBuilder().Clear(); + Score = null; + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/ICachePersister.cs b/src/Trash/Radarr/CustomFormat/ICachePersister.cs new file mode 100644 index 00000000..896bff68 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/ICachePersister.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat +{ + public interface ICachePersister + { + CustomFormatCache? CfCache { get; } + void Load(); + void Save(); + void Update(IEnumerable customFormats); + } +} diff --git a/src/Trash/Radarr/CustomFormat/ICustomFormatUpdater.cs b/src/Trash/Radarr/CustomFormat/ICustomFormatUpdater.cs new file mode 100644 index 00000000..b1a15c60 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/ICustomFormatUpdater.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Trash.Command; + +namespace Trash.Radarr.CustomFormat +{ + public interface ICustomFormatUpdater + { + Task Process(IServiceCommand args, RadarrConfiguration config); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Models/Cache/CustomFormatMapping.cs b/src/Trash/Radarr/CustomFormat/Models/Cache/CustomFormatMapping.cs new file mode 100644 index 00000000..46b5aea0 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/Cache/CustomFormatMapping.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Trash.Cache; + +namespace Trash.Radarr.CustomFormat.Models.Cache +{ + [CacheObjectName("custom-format-cache")] + public class CustomFormatCache + { + public List TrashIdMappings { get; init; } = new(); + } + + public class TrashIdMapping + { + public TrashIdMapping(string trashId, string customFormatName, int customFormatId = default) + { + CustomFormatName = customFormatName; + TrashId = trashId; + CustomFormatId = customFormatId; + } + + public string CustomFormatName { get; set; } + public string TrashId { get; } + public int CustomFormatId { get; set; } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Models/ProcessedConfigData.cs b/src/Trash/Radarr/CustomFormat/Models/ProcessedConfigData.cs new file mode 100644 index 00000000..923114bd --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/ProcessedConfigData.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace Trash.Radarr.CustomFormat.Models +{ + public class ProcessedConfigData + { + public List CustomFormats { get; init; } = new(); + public List QualityProfiles { get; init; } = new(); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Models/ProcessedCustomFormatData.cs b/src/Trash/Radarr/CustomFormat/Models/ProcessedCustomFormatData.cs new file mode 100644 index 00000000..436aa4cc --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/ProcessedCustomFormatData.cs @@ -0,0 +1,36 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json.Linq; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Models +{ + public class ProcessedCustomFormatData + { + public ProcessedCustomFormatData(string name, string trashId, JObject json) + { + Name = name; + TrashId = trashId; + Json = json; + } + + public string Name { get; } + public string TrashId { get; } + public int? Score { get; init; } + public JObject Json { get; set; } + public TrashIdMapping? CacheEntry { get; set; } + + public string CacheAwareName => CacheEntry?.CustomFormatName ?? Name; + + public void SetCache(int customFormatId) + { + CacheEntry ??= new TrashIdMapping(TrashId, Name); + CacheEntry.CustomFormatId = customFormatId; + } + + [SuppressMessage("Microsoft.Design", "CA1024", Justification = "Method throws an exception")] + public int GetCustomFormatId() + => CacheEntry?.CustomFormatId ?? + throw new InvalidOperationException("CacheEntry must exist to obtain custom format ID"); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs b/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs new file mode 100644 index 00000000..48ce86ca --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs @@ -0,0 +1,14 @@ +namespace Trash.Radarr.CustomFormat.Models +{ + public class QualityProfileCustomFormatScoreEntry + { + public QualityProfileCustomFormatScoreEntry(ProcessedCustomFormatData customFormat, int score) + { + CustomFormat = customFormat; + Score = score; + } + + public ProcessedCustomFormatData CustomFormat { get; } + public int Score { get; } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs new file mode 100644 index 00000000..3c8162b5 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Serilog; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.GuideSteps; + +namespace Trash.Radarr.CustomFormat.Processors +{ + public interface IGuideProcessorSteps + { + ICustomFormatStep CustomFormat { get; } + IConfigStep Config { get; } + IQualityProfileStep QualityProfile { get; } + } + + internal class GuideProcessor : IGuideProcessor + { + private readonly ICustomFormatGuideParser _guideParser; + private readonly Func _stepsFactory; + private IList? _guideData; + private IGuideProcessorSteps _steps; + + public GuideProcessor(ILogger log, ICustomFormatGuideParser guideParser, + Func stepsFactory) + { + _guideParser = guideParser; + _stepsFactory = stepsFactory; + Log = log; + _steps = stepsFactory(); + } + + private ILogger Log { get; } + + public IReadOnlyCollection ProcessedCustomFormats + => _steps.CustomFormat.ProcessedCustomFormats; + + public IReadOnlyCollection CustomFormatsNotInGuide + => _steps.Config.CustomFormatsNotInGuide; + + public IReadOnlyCollection ConfigData + => _steps.Config.ConfigData; + + public IDictionary> ProfileScores + => _steps.QualityProfile.ProfileScores; + + public IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore + => _steps.QualityProfile.CustomFormatsWithoutScore; + + public IReadOnlyCollection DeletedCustomFormatsInCache + => _steps.CustomFormat.DeletedCustomFormatsInCache; + + public List<(string, string)> CustomFormatsWithOutdatedNames + => _steps.CustomFormat.CustomFormatsWithOutdatedNames; + + public async Task BuildGuideData(IReadOnlyList config, CustomFormatCache? cache) + { + if (_guideData == null) + { + Log.Debug("Requesting and parsing guide markdown"); + var markdownData = await _guideParser.GetMarkdownData(); + _guideData = _guideParser.ParseMarkdown(markdownData); + } + + // Step 1: Process and filter the custom formats from the guide. + // Custom formats in the guide not mentioned in the config are filtered out. + _steps.CustomFormat.Process(_guideData, config, cache); + + // todo: Process cache entries that do not exist in the guide. Those should be deleted + // This might get taken care of when we rebuild the cache based on what is actually updated when + // we call the Radarr API + + // Step 2: Use the processed custom formats from step 1 to process the configuration. + // CFs in config not in the guide are filtered out. + // Actual CF objects are associated to the quality profile objects to reduce lookups + _steps.Config.Process(_steps.CustomFormat.ProcessedCustomFormats, config); + + // Step 3: Use the processed config (which contains processed CFs) to process the quality profile scores. + // Score precedence logic is utilized here to decide the CF score per profile (same CF can actually have + // different scores depending on which profile it goes into). + _steps.QualityProfile.Process(_steps.Config.ConfigData); + } + + public void Reset() + { + _steps = _stepsFactory(); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs new file mode 100644 index 00000000..397be0bb --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public class ConfigStep : IConfigStep + { + public List RenamedCustomFormats { get; private set; } = new(); + public List CustomFormatsNotInGuide { get; } = new(); + public List ConfigData { get; } = new(); + + public void Process(IReadOnlyCollection processedCfs, + IEnumerable config) + { + foreach (var configCf in config) + { + // Also get the list of CFs that are in the guide + var cfsInGuide = configCf.Names + .ToLookup(n => + { + // Iterate up to two times: + // 1. Find a match in the cache using name in config. If not found, + // 2. Find a match in the guide using name in config. + return processedCfs.FirstOrDefault( + cf => cf.CacheEntry?.CustomFormatName.EqualsIgnoreCase(n) ?? false) ?? + processedCfs.FirstOrDefault( + cf => cf.Name.EqualsIgnoreCase(n)); + }); + + // Names grouped under 'null' were not found in the guide OR the cache + CustomFormatsNotInGuide.AddRange( + cfsInGuide[null].Distinct(StringComparer.CurrentCultureIgnoreCase)); + + ConfigData.Add(new ProcessedConfigData + { + CustomFormats = cfsInGuide.Where(grp => grp.Key != null).Select(grp => grp.Key!).ToList(), + QualityProfiles = configCf.QualityProfiles + }); + } + + var allCfs = ConfigData + .SelectMany(cd => cd.CustomFormats.Select(cf => cf)) + .Distinct() + .ToList(); + + // List of CFs in cache vs guide that have mismatched Trash ID. This means that a CF was renamed + // to the same name as a previous CF's name, and we should treat that one as missing. + // CustomFormatsSameNameDiffTrashId = allCfs + // .Where(cf => cf.CacheEntry != null) + // .GroupBy(cf => allCfs.FirstOrDefault( + // cf2 => cf2.Name.EqualsIgnoreCase(cf.CacheEntry!.CustomFormatName) && + // !cf2.TrashId.EqualsIgnoreCase(cf.CacheEntry.TrashId))) + // .Where(grp => grp.Key != null) + // .Select(grp => grp.Append(grp.Key!).ToList()) + // .ToList(); + + // CFs in the guide that match the same TrashID in cache but have different names. Warn the user that it + // is renamed in the guide and they need to update their config. + RenamedCustomFormats = allCfs + .Where(cf => cf.CacheEntry != null && !cf.CacheEntry.CustomFormatName.EqualsIgnoreCase(cf.Name)) + .ToList(); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs new file mode 100644 index 00000000..51cff9f6 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public class CustomFormatStep : ICustomFormatStep + { + public List<(string, string)> CustomFormatsWithOutdatedNames { get; } = new(); + public List ProcessedCustomFormats { get; } = new(); + public List DeletedCustomFormatsInCache { get; } = new(); + + public void Process(IEnumerable customFormatGuideData, IEnumerable config, + CustomFormatCache? cache) + { + var allConfigCfNames = config + .SelectMany(c => c.Names) + .Distinct(StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + var processedCfs = customFormatGuideData + .Select(cf => ProcessCustomFormatData(cf, cache)) + .ToList(); + + // Perform updates and deletions based on matches in the cache. Matches in the cache are by ID. + foreach (var cf in processedCfs) //.Where(cf => cf.CacheEntry != null)) + { + // Does the name of the CF in the guide match a name in the config? If yes, we keep it. + var configName = allConfigCfNames.FirstOrDefault(n => n.EqualsIgnoreCase(cf.Name)); + if (configName != null) + { + if (cf.CacheEntry != null) + { + // The cache entry might be using an old name. This will happen if: + // - A user has synced this CF before, AND + // - The name of the CF in the guide changed, AND + // - The user updated the name in their config to match the name in the guide. + cf.CacheEntry.CustomFormatName = cf.Name; + } + + ProcessedCustomFormats.Add(cf); + continue; + } + + // Does the name of the CF in the cache match a name in the config? If yes, we keep it. + configName = allConfigCfNames.FirstOrDefault(n => n.EqualsIgnoreCase(cf.CacheEntry?.CustomFormatName)); + if (configName != null) + { + // Config name is out of sync with the guide and should be updated + CustomFormatsWithOutdatedNames.Add((configName, cf.Name)); + ProcessedCustomFormats.Add(cf); + } + + // If we get here, we can't find a match in the config using cache or guide name, so the user must have + // removed it from their config. This will get marked for deletion when we process those later in + // ProcessDeletedCustomFormats(). + } + + // Orphaned entries in cache represent custom formats we need to delete. + ProcessDeletedCustomFormats(cache); + } + + private static ProcessedCustomFormatData ProcessCustomFormatData(CustomFormatData guideData, + CustomFormatCache? cache) + { + JObject obj = JObject.Parse(guideData.Json); + var name = obj["name"].Value(); + var trashId = obj["trash_id"].Value(); + + // Remove trash_id, it's metadata that is not meant for Radarr itself + // Radarr supposedly drops this anyway, but I prefer it to be removed by TrashUpdater + obj.Property("trash_id").Remove(); + + return new ProcessedCustomFormatData(name, trashId, obj) + { + Score = guideData.Score, + CacheEntry = cache?.TrashIdMappings.FirstOrDefault(c => c.TrashId == trashId) + }; + } + + private void ProcessDeletedCustomFormats(CustomFormatCache? cache) + { + if (cache == null) + { + return; + } + + static bool MatchCfInCache(ProcessedCustomFormatData cf, TrashIdMapping c) + => cf.CacheEntry != null && cf.CacheEntry.TrashId == c.TrashId; + + // Delete if CF is in cache and not in the guide or config + DeletedCustomFormatsInCache.AddRange(cache.TrashIdMappings + .Where(c => !ProcessedCustomFormats.Any(cf => MatchCfInCache(cf, c)))); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IConfigStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IConfigStep.cs new file mode 100644 index 00000000..789c6518 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IConfigStep.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public interface IConfigStep + { + List RenamedCustomFormats { get; } + List CustomFormatsNotInGuide { get; } + List ConfigData { get; } + + void Process(IReadOnlyCollection processedCfs, + IEnumerable config); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs new file mode 100644 index 00000000..a178fc38 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Trash.Radarr.CustomFormat.Guide; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public interface ICustomFormatStep + { + List ProcessedCustomFormats { get; } + List DeletedCustomFormatsInCache { get; } + List<(string, string)> CustomFormatsWithOutdatedNames { get; } + + void Process(IEnumerable customFormatGuideData, IEnumerable config, + CustomFormatCache? cache); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs new file mode 100644 index 00000000..f974b923 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public interface IQualityProfileStep + { + Dictionary> ProfileScores { get; } + List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } + void Process(IEnumerable configData); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs new file mode 100644 index 00000000..46b35ba3 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.GuideSteps +{ + public class QualityProfileStep : IQualityProfileStep + { + public Dictionary> ProfileScores { get; } = new(); + public List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } = new(); + + public void Process(IEnumerable configData) + { + foreach (var config in configData) + foreach (var profile in config.QualityProfiles) + foreach (var cf in config.CustomFormats) + { + // Check if there is a score we can use. Priority is: + // 1. Score from the YAML config is used. If user did not provide, + // 2. Score from the guide is used. If the guide did not have one, + // 3. Warn the user and skip it. + var scoreToUse = profile.Score; + if (scoreToUse == null) + { + if (cf.Score == null) + { + CustomFormatsWithoutScore.Add((cf.Name, cf.TrashId, profile.Name)); + } + else + { + scoreToUse = cf.Score.Value; + } + } + + if (scoreToUse != null) + { + ProfileScores.GetOrCreate(profile.Name) + .Add(new QualityProfileCustomFormatScoreEntry(cf, scoreToUse.Value)); + } + } + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs new file mode 100644 index 00000000..f60ace3c --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Processors +{ + internal interface IGuideProcessor + { + IReadOnlyCollection ProcessedCustomFormats { get; } + IReadOnlyCollection CustomFormatsNotInGuide { get; } + IReadOnlyCollection ConfigData { get; } + IDictionary> ProfileScores { get; } + IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } + IReadOnlyCollection DeletedCustomFormatsInCache { get; } + List<(string, string)> CustomFormatsWithOutdatedNames { get; } + + Task BuildGuideData(IReadOnlyList config, CustomFormatCache? cache); + void Reset(); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs new file mode 100644 index 00000000..d21cf70a --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Radarr.CustomFormat.Processors +{ + public interface IPersistenceProcessor + { + IDictionary> UpdatedScores { get; } + IReadOnlyCollection InvalidProfileNames { get; } + CustomFormatTransactionData Transactions { get; } + + Task PersistCustomFormats(IReadOnlyCollection guideCfs, + IEnumerable deletedCfsInCache, + IDictionary> profileScores); + + void Reset(); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs new file mode 100644 index 00000000..aaee8d99 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Trash.Config; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; +using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; + +namespace Trash.Radarr.CustomFormat.Processors +{ + public interface IPersistenceProcessorSteps + { + public IJsonTransactionStep JsonTransactionStep { get; } + public ICustomFormatApiPersistenceStep CustomFormatCustomFormatApiPersister { get; } + public IQualityProfileApiPersistenceStep ProfileQualityProfileApiPersister { get; } + } + + internal class PersistenceProcessor : IPersistenceProcessor + { + private readonly RadarrConfiguration _config; + private readonly ICustomFormatService _customFormatService; + private readonly IQualityProfileService _qualityProfileService; + private readonly Func _stepsFactory; + private IPersistenceProcessorSteps _steps; + + public PersistenceProcessor( + ICustomFormatService customFormatService, + IQualityProfileService qualityProfileService, + IServiceConfiguration config, + Func stepsFactory) + { + _customFormatService = customFormatService; + _qualityProfileService = qualityProfileService; + _stepsFactory = stepsFactory; + _config = (RadarrConfiguration) config; + _steps = _stepsFactory(); + } + + public CustomFormatTransactionData Transactions + => _steps.JsonTransactionStep.Transactions; + + public IDictionary> UpdatedScores + => _steps.ProfileQualityProfileApiPersister.UpdatedScores; + + public IReadOnlyCollection InvalidProfileNames + => _steps.ProfileQualityProfileApiPersister.InvalidProfileNames; + + public void Reset() + { + _steps = _stepsFactory(); + } + + public async Task PersistCustomFormats(IReadOnlyCollection guideCfs, + IEnumerable deletedCfsInCache, + IDictionary> profileScores) + { + var radarrCfs = await _customFormatService.GetCustomFormats(); + + // Step 1: Match CFs between the guide & Radarr and merge the data. The goal is to retain as much of the + // original data from Radarr as possible. There are many properties in the response JSON that we don't + // directly care about. We keep those and just update the ones we do care about. + _steps.JsonTransactionStep.Process(guideCfs, radarrCfs); + + // Step 1.1: Optionally record deletions of custom formats in cache but not in the guide + if (_config.DeleteOldCustomFormats) + { + _steps.JsonTransactionStep.RecordDeletions(deletedCfsInCache, radarrCfs); + } + + // Step 2: For each merged CF, persist it to Radarr via its API. This will involve a combination of updates + // to existing CFs and creation of brand new ones, depending on what's already available in Radarr. + await _steps.CustomFormatCustomFormatApiPersister.Process(_customFormatService, + _steps.JsonTransactionStep.Transactions); + + // Step 3: Update all quality profiles with the scores from the guide for the uploaded custom formats + await _steps.ProfileQualityProfileApiPersister.Process(_qualityProfileService, profileScores); + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStep.cs new file mode 100644 index 00000000..92252cea --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/CustomFormatApiPersistenceStep.cs @@ -0,0 +1,26 @@ +using System.Threading.Tasks; +using Trash.Radarr.CustomFormat.Api; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public class CustomFormatApiPersistenceStep : ICustomFormatApiPersistenceStep + { + public async Task Process(ICustomFormatService api, CustomFormatTransactionData transactions) + { + foreach (var cf in transactions.NewCustomFormats) + { + await api.CreateCustomFormat(cf); + } + + foreach (var cf in transactions.UpdatedCustomFormats) + { + await api.UpdateCustomFormat(cf); + } + + foreach (var cfId in transactions.DeletedCustomFormatIds) + { + await api.DeleteCustomFormat(cfId.CustomFormatId); + } + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/ICustomFormatApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/ICustomFormatApiPersistenceStep.cs new file mode 100644 index 00000000..79277c95 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/ICustomFormatApiPersistenceStep.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Trash.Radarr.CustomFormat.Api; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public interface ICustomFormatApiPersistenceStep + { + Task Process(ICustomFormatService api, CustomFormatTransactionData transactions); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IJsonTransactionStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IJsonTransactionStep.cs new file mode 100644 index 00000000..7cd459b1 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IJsonTransactionStep.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public interface IJsonTransactionStep + { + CustomFormatTransactionData Transactions { get; } + + void Process(IEnumerable guideCfs, + IReadOnlyCollection radarrCfs); + + void RecordDeletions(IEnumerable deletedCfsInCache, List radarrCfs); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs new file mode 100644 index 00000000..40f238ac --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public interface IQualityProfileApiPersistenceStep + { + IDictionary> UpdatedScores { get; } + IReadOnlyCollection InvalidProfileNames { get; } + + Task Process(IQualityProfileService api, + IDictionary> cfScores); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStep.cs new file mode 100644 index 00000000..627b1221 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/JsonTransactionStep.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Models; +using Trash.Radarr.CustomFormat.Models.Cache; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public class CustomFormatTransactionData + { + public List NewCustomFormats { get; } = new(); + public List UpdatedCustomFormats { get; } = new(); + public List DeletedCustomFormatIds { get; } = new(); + public List UnchangedCustomFormats { get; } = new(); + } + + public class JsonTransactionStep : IJsonTransactionStep + { + public CustomFormatTransactionData Transactions { get; } = new(); + + public void Process(IEnumerable guideCfs, + IReadOnlyCollection radarrCfs) + { + foreach (var (guideCf, radarrCf) in guideCfs + .Select(gcf => (GuideCf: gcf, RadarrCf: FindRadarrCf(radarrCfs, gcf)))) + { + var guideCfJson = BuildNewRadarrCf(guideCf.Json); + + // no match; we add this CF as brand new + if (radarrCf == null) + { + guideCf.Json = guideCfJson; + Transactions.NewCustomFormats.Add(guideCf); + } + // found match in radarr CFs; update the existing CF + else + { + guideCf.Json = (JObject) radarrCf.DeepClone(); + UpdateRadarrCf(guideCf.Json, guideCfJson); + + if (!JToken.DeepEquals(radarrCf, guideCf.Json)) + { + Transactions.UpdatedCustomFormats.Add(guideCf); + } + else + { + Transactions.UnchangedCustomFormats.Add(guideCf); + } + } + } + } + + public void RecordDeletions(IEnumerable deletedCfsInCache, List radarrCfs) + { + // The 'Where' excludes cached CFs that were deleted manually by the user in Radarr + // FindRadarrCf() specifies 'null' for name because we should never delete unless an ID is found + foreach (var del in deletedCfsInCache.Where( + del => FindRadarrCf(radarrCfs, del.CustomFormatId, null) != null)) + { + Transactions.DeletedCustomFormatIds.Add(del); + } + } + + private static JObject? FindRadarrCf(IReadOnlyCollection radarrCfs, ProcessedCustomFormatData guideCf) + { + return FindRadarrCf(radarrCfs, guideCf.CacheEntry?.CustomFormatId, guideCf.Name); + } + + private static JObject? FindRadarrCf(IReadOnlyCollection radarrCfs, int? cfId, string? cfName) + { + JObject? match = null; + + // Try to find match in cache first + if (cfId != null) + { + match = radarrCfs.FirstOrDefault(rcf => cfId == rcf["id"].Value()); + } + + // If we don't find by ID, search by name (if a name was given) + if (match == null && cfName != null) + { + match = radarrCfs.FirstOrDefault(rcf => cfName.EqualsIgnoreCase(rcf["name"].Value())); + } + + return match; + } + + private static void UpdateRadarrCf(JObject cfToModify, JObject cfToMergeFrom) + { + MergeProperties(cfToModify, cfToMergeFrom, JTokenType.Array); + + var radarrSpecs = cfToModify["specifications"].Children(); + var guideSpecs = cfToMergeFrom["specifications"].Children(); + + var matchedGuideSpecs = guideSpecs + .GroupBy(gs => radarrSpecs.FirstOrDefault(gss => KeyMatch(gss, gs, "name"))) + .SelectMany(kvp => kvp.Select(gs => new {GuideSpec = gs, RadarrSpec = kvp.Key})); + + var newRadarrSpecs = new JArray(); + + foreach (var match in matchedGuideSpecs) + { + if (match.RadarrSpec != null) + { + MergeProperties(match.RadarrSpec, match.GuideSpec); + newRadarrSpecs.Add(match.RadarrSpec); + } + else + { + newRadarrSpecs.Add(match.GuideSpec); + } + } + + cfToModify["specifications"] = newRadarrSpecs; + } + + private static bool KeyMatch(JObject left, JObject right, string keyName) + => left[keyName].Value() == right[keyName].Value(); + + private static void MergeProperties(JObject radarrCf, JObject guideCfJson, + JTokenType exceptType = JTokenType.None) + { + foreach (var guideProp in guideCfJson.Properties().Where(p => p.Value.Type != exceptType)) + { + if (guideProp.Value.Type == JTokenType.Array && + radarrCf.TryGetValue(guideProp.Name, out var radarrArray)) + { + ((JArray) radarrArray).Merge(guideProp.Value, new JsonMergeSettings + { + MergeArrayHandling = MergeArrayHandling.Merge + }); + } + else + { + radarrCf[guideProp.Name] = guideProp.Value; + } + } + } + + private static JObject BuildNewRadarrCf(JObject jsonPayload) + { + // Information on required fields from nitsua + /* + ok, for the specs.. you need name, implementation, negate, required, fields + for fields you need name & value + top level you need name, includeCustomFormatWhenRenaming, specs and id (if updating) + everything else radarr can handle with backend logic + */ + + foreach (var child in jsonPayload["specifications"]) + { + // convert from `"fields": {}` to `"fields": [{}]` (object to array of object) + // Weirdly the exported version of a custom format is not in array form, but the API requires the array + // even if there's only one element. + var field = child["fields"]; + field["name"] = "value"; + child["fields"] = new JArray {field}; + } + + return jsonPayload; + } + } +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs new file mode 100644 index 00000000..8ddf9bb9 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Trash.Extensions; +using Trash.Radarr.CustomFormat.Api; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps +{ + public class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceStep + { + private readonly List _invalidProfileNames = new(); + private readonly Dictionary> _updatedScores = new(); + + public IDictionary> UpdatedScores => _updatedScores; + public IReadOnlyCollection InvalidProfileNames => _invalidProfileNames; + + public async Task Process(IQualityProfileService api, + IDictionary> cfScores) + { + var radarrProfiles = (await api.GetQualityProfiles()) + .Select(p => (Name: p["name"].ToString(), Json: p)) + .ToList(); + + var profileScores = cfScores + .GroupJoin(radarrProfiles, + s => s.Key, + p => p.Name, + (s, pList) => (s.Key, s.Value, + pList.SelectMany(p => p.Json["formatItems"].Children()).ToList()), + StringComparer.InvariantCultureIgnoreCase); + + foreach (var (profileName, scoreList, jsonList) in profileScores) + { + if (jsonList.Count == 0) + { + _invalidProfileNames.Add(profileName); + continue; + } + + foreach (var (score, json) in scoreList + .Select(s => (s, FindJsonScoreEntry(s, jsonList))) + .Where(p => p.Item2 != null)) + { + var currentScore = (int) json!["score"]; + if (currentScore == score.Score) + { + continue; + } + + json!["score"] = score.Score; + _updatedScores.GetOrCreate(profileName).Add(score); + } + + var jsonRoot = (JObject) jsonList.First().Root; + await api.UpdateQualityProfile(jsonRoot, (int) jsonRoot["id"]); + } + } + + private static JObject? FindJsonScoreEntry(QualityProfileCustomFormatScoreEntry score, + IEnumerable jsonList) + { + return jsonList.FirstOrDefault(j + => score.CustomFormat.CacheEntry != null && + (int) j["format"] == score.CustomFormat.CacheEntry.CustomFormatId); + } + } +} diff --git a/src/Trash/Radarr/Api/IRadarrApi.cs b/src/Trash/Radarr/QualityDefinition/Api/IQualityDefinitionService.cs similarity index 66% rename from src/Trash/Radarr/Api/IRadarrApi.cs rename to src/Trash/Radarr/QualityDefinition/Api/IQualityDefinitionService.cs index 2e81232c..7f0f955e 100644 --- a/src/Trash/Radarr/Api/IRadarrApi.cs +++ b/src/Trash/Radarr/QualityDefinition/Api/IQualityDefinitionService.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Trash.Radarr.Api.Objects; +using Trash.Radarr.QualityDefinition.Api.Objects; -namespace Trash.Radarr.Api +namespace Trash.Radarr.QualityDefinition.Api { - public interface IRadarrApi + public interface IQualityDefinitionService { Task> GetQualityDefinition(); Task> UpdateQualityDefinition(IList newQuality); diff --git a/src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs b/src/Trash/Radarr/QualityDefinition/Api/Objects/RadarrQualityDefinitionItem.cs similarity index 93% rename from src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs rename to src/Trash/Radarr/QualityDefinition/Api/Objects/RadarrQualityDefinitionItem.cs index 325619d3..0ae6e4e2 100644 --- a/src/Trash/Radarr/Api/Objects/RadarrQualityDefinitionItem.cs +++ b/src/Trash/Radarr/QualityDefinition/Api/Objects/RadarrQualityDefinitionItem.cs @@ -1,6 +1,6 @@ using JetBrains.Annotations; -namespace Trash.Radarr.Api.Objects +namespace Trash.Radarr.QualityDefinition.Api.Objects { [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class RadarrQualityItem diff --git a/src/Trash/Radarr/Api/RadarrApi.cs b/src/Trash/Radarr/QualityDefinition/Api/QualityDefinitionService.cs similarity index 69% rename from src/Trash/Radarr/Api/RadarrApi.cs rename to src/Trash/Radarr/QualityDefinition/Api/QualityDefinitionService.cs index 3b2ccfbe..2952770d 100644 --- a/src/Trash/Radarr/Api/RadarrApi.cs +++ b/src/Trash/Radarr/QualityDefinition/Api/QualityDefinitionService.cs @@ -3,22 +3,24 @@ using System.Threading.Tasks; using Flurl; using Flurl.Http; using Trash.Config; -using Trash.Radarr.Api.Objects; +using Trash.Radarr.QualityDefinition.Api.Objects; -namespace Trash.Radarr.Api +namespace Trash.Radarr.QualityDefinition.Api { - public class RadarrApi : IRadarrApi + public class QualityDefinitionService : IQualityDefinitionService { private readonly IServiceConfiguration _serviceConfig; - public RadarrApi(IServiceConfiguration serviceConfig) + public QualityDefinitionService(IServiceConfiguration serviceConfig) { _serviceConfig = serviceConfig; } + private string BaseUrl => _serviceConfig.BuildUrl(); + public async Task> GetQualityDefinition() { - return await BaseUrl() + return await BaseUrl .AppendPathSegment("qualitydefinition") .GetJsonAsync>(); } @@ -26,15 +28,10 @@ namespace Trash.Radarr.Api public async Task> UpdateQualityDefinition( IList newQuality) { - return await BaseUrl() + return await BaseUrl .AppendPathSegment("qualityDefinition/update") .PutJsonAsync(newQuality) .ReceiveJson>(); } - - private string BaseUrl() - { - return _serviceConfig.BuildUrl(); - } } } diff --git a/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs index 1a107675..221f1b19 100644 --- a/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs +++ b/src/Trash/Radarr/QualityDefinition/RadarrQualityDefinitionUpdater.cs @@ -3,18 +3,18 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Serilog; -using Trash.Radarr.Api; -using Trash.Radarr.Api.Objects; +using Trash.Radarr.QualityDefinition.Api; +using Trash.Radarr.QualityDefinition.Api.Objects; namespace Trash.Radarr.QualityDefinition { public class RadarrQualityDefinitionUpdater { - private readonly IRadarrApi _api; + private readonly IQualityDefinitionService _api; private readonly IRadarrQualityDefinitionGuideParser _parser; public RadarrQualityDefinitionUpdater(ILogger logger, IRadarrQualityDefinitionGuideParser parser, - IRadarrApi api) + IQualityDefinitionService api) { Log = logger; _parser = parser; diff --git a/src/Trash/Radarr/RadarrCommand.cs b/src/Trash/Radarr/RadarrCommand.cs index 03d97ca7..979497cf 100644 --- a/src/Trash/Radarr/RadarrCommand.cs +++ b/src/Trash/Radarr/RadarrCommand.cs @@ -8,6 +8,7 @@ using Serilog; using Serilog.Core; using Trash.Command; using Trash.Config; +using Trash.Radarr.CustomFormat; using Trash.Radarr.QualityDefinition; namespace Trash.Radarr @@ -17,21 +18,24 @@ namespace Trash.Radarr public class RadarrCommand : ServiceCommand, IRadarrCommand { private readonly IConfigurationLoader _configLoader; + private readonly Lazy _customFormatUpdater; private readonly Func _qualityUpdaterFactory; public RadarrCommand( ILogger logger, LoggingLevelSwitch loggingLevelSwitch, IConfigurationLoader configLoader, - Func qualityUpdaterFactory) + Func qualityUpdaterFactory, + Lazy customFormatUpdater) : base(logger, loggingLevelSwitch) { _configLoader = configLoader; _qualityUpdaterFactory = qualityUpdaterFactory; + _customFormatUpdater = customFormatUpdater; } public override string CacheStoragePath { get; } = - Path.Join(AppPaths.AppDataPath, "cache/radarr"); + Path.Combine(AppPaths.AppDataPath, "cache", "radarr"); public override async Task Process() { @@ -43,6 +47,11 @@ namespace Trash.Radarr { await _qualityUpdaterFactory().Process(this, config); } + + if (config.CustomFormats.Count > 0) + { + await _customFormatUpdater.Value.Process(this, config); + } } } catch (FlurlHttpException e) diff --git a/src/Trash/Radarr/RadarrConfiguration.cs b/src/Trash/Radarr/RadarrConfiguration.cs index 67457bbc..001d8d3f 100644 --- a/src/Trash/Radarr/RadarrConfiguration.cs +++ b/src/Trash/Radarr/RadarrConfiguration.cs @@ -1,8 +1,10 @@ -using System.ComponentModel.DataAnnotations; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using Flurl; using JetBrains.Annotations; using Trash.Config; using Trash.Radarr.QualityDefinition; +using Trash.YamlDotNet; namespace Trash.Radarr { @@ -10,6 +12,8 @@ namespace Trash.Radarr public class RadarrConfiguration : ServiceConfiguration { public QualityDefinitionConfig? QualityDefinition { get; init; } + public List CustomFormats { get; set; } = new(); + public bool DeleteOldCustomFormats { get; set; } public override string BuildUrl() { @@ -19,6 +23,24 @@ namespace Trash.Radarr } } + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + public class CustomFormatConfig + { + [CannotBeEmpty] + public List Names { get; set; } = new(); + + public List QualityProfiles { get; set; } = new(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] + public class QualityProfileConfig + { + [Required] + public string Name { get; set; } = ""; + + public int? Score { get; set; } + } + [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class QualityDefinitionConfig { diff --git a/src/Trash/Sonarr/Api/ISonarrApi.cs b/src/Trash/Sonarr/Api/ISonarrApi.cs index 00b84f4e..87b117db 100644 --- a/src/Trash/Sonarr/Api/ISonarrApi.cs +++ b/src/Trash/Sonarr/Api/ISonarrApi.cs @@ -14,6 +14,8 @@ namespace Trash.Sonarr.Api Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate); Task CreateReleaseProfile(SonarrReleaseProfile newProfile); Task> GetQualityDefinition(); - Task> UpdateQualityDefinition(IReadOnlyCollection newQuality); + + Task> UpdateQualityDefinition( + IReadOnlyCollection newQuality); } } diff --git a/src/Trash/Sonarr/ReleaseProfile/ParserState.cs b/src/Trash/Sonarr/ReleaseProfile/ParserState.cs index 06b181b7..5100ec77 100644 --- a/src/Trash/Sonarr/ReleaseProfile/ParserState.cs +++ b/src/Trash/Sonarr/ReleaseProfile/ParserState.cs @@ -12,7 +12,6 @@ namespace Trash.Sonarr.ReleaseProfile Preferred } - public class ParserState { public ParserState(ILogger logger) diff --git a/src/Trash/Sonarr/SonarrCommand.cs b/src/Trash/Sonarr/SonarrCommand.cs index 4ddce8c8..adb2b835 100644 --- a/src/Trash/Sonarr/SonarrCommand.cs +++ b/src/Trash/Sonarr/SonarrCommand.cs @@ -37,7 +37,7 @@ namespace Trash.Sonarr // todo: Add options to exclude parts of YAML on the fly? public override string CacheStoragePath { get; } = - Path.Join(AppPaths.AppDataPath, "cache/sonarr"); + Path.Combine(AppPaths.AppDataPath, "cache", "sonarr"); public override async Task Process() { diff --git a/src/Trash/Trash.csproj b/src/Trash/Trash.csproj index 5c34f742..21a9d26b 100644 --- a/src/Trash/Trash.csproj +++ b/src/Trash/Trash.csproj @@ -6,17 +6,17 @@ - - - + + + - + + - - - - + + + diff --git a/src/Trash/YamlDotNet/CannotBeEmptyAttribute.cs b/src/Trash/YamlDotNet/CannotBeEmptyAttribute.cs new file mode 100644 index 00000000..cb3df545 --- /dev/null +++ b/src/Trash/YamlDotNet/CannotBeEmptyAttribute.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections; +using System.ComponentModel.DataAnnotations; + +namespace Trash.YamlDotNet +{ + [AttributeUsage(AttributeTargets.Property)] + public sealed class CannotBeEmptyAttribute : RequiredAttribute + { + public override bool IsValid(object? value) + { + return base.IsValid(value) && + value is IEnumerable list && + list.GetEnumerator().MoveNext(); + } + } +} diff --git a/src/Trash/trash-config-template.yml b/src/Trash/trash-config-template.yml index 37cd56c2..2c56732f 100644 --- a/src/Trash/trash-config-template.yml +++ b/src/Trash/trash-config-template.yml @@ -31,3 +31,26 @@ radarr: # Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie' quality_definition: type: movie + + # Set to 'true' to automatically remove custom formats from Radarr when they are removed from + # the guide or your configuration. This will NEVER delete custom formats you manually created! + delete_old_custom_formats: false + + custom_formats: + # A list of custom formats to sync to Radarr. Must match the "name" in the importable JSON. + # Do NOT use the heading names here, those do not work! These are case-insensitive. + - names: + - BR-DISK + - EVO (no WEB-DL) + - LQ + - x265 (720/1080p) + - 3D + + # Uncomment the below properties to specify one or more quality profiles that should be + # updated with scores from the guide for each custom format. Without this, custom formats + # are synced to Radarr but no scores are set in any quality profiles. + +# quality_profiles: +# - name: Quality Profile 1 +# - name: Quality Profile 2 +# #score: -9999 # Optional score to assign to all CFs. Overrides scores in the guide. diff --git a/src/TrashUpdater.sln.DotSettings b/src/TrashUpdater.sln.DotSettings index b2e45fce..b88769e4 100644 --- a/src/TrashUpdater.sln.DotSettings +++ b/src/TrashUpdater.sln.DotSettings @@ -8,5 +8,9 @@ &lt;option name="myName" value="TrashUpdaterCleanup" /&gt; &lt;/profile&gt;</IDEA_SETTINGS><CSShortenReferences>True</CSShortenReferences><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> TrashUpdaterCleanup + True + True + True + True True True \ No newline at end of file